Compare commits
78 Commits
b0bbb4d465
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0009af751f | |||
| 4063ded9ec | |||
|
|
f0faff412c | ||
|
|
08db97faa8 | ||
|
|
054c82529b | ||
|
|
8437cadee0 | ||
|
|
b4af83b879 | ||
|
|
b1204c95cb | ||
|
|
a5b98f93e6 | ||
|
|
b2ef342bf4 | ||
|
|
e405170c8b | ||
|
|
fd99f6f390 | ||
|
|
4e6040a781 | ||
|
|
7904475ddc | ||
|
|
a84bbcac31 | ||
|
|
569d0a41e2 | ||
|
|
c6c32a168b | ||
|
|
cba8218fe8 | ||
|
|
695feff443 | ||
|
|
f85cc084b7 | ||
|
|
32f0d05038 | ||
|
|
d84a15ad64 | ||
|
|
b26382f427 | ||
|
|
56c7b90262 | ||
|
|
5e4dd7403e | ||
|
|
a717e122f0 | ||
|
|
732390425f | ||
|
|
a1694668d9 | ||
|
|
f809f9171b | ||
|
|
fa269db3ef | ||
|
|
ef3e57bd5a | ||
|
|
ed6d09f3bc | ||
|
|
e2498af481 | ||
|
|
d7f046a4d6 | ||
|
|
7c00ae5000 | ||
|
|
7ee28c7ca3 | ||
|
|
e7dac31d22 | ||
|
|
c25dafb14c | ||
|
|
05f8994966 | ||
|
|
040c1e4d70 | ||
|
|
a31dda4e9e | ||
|
|
b276cc9948 | ||
|
|
53297a7cff | ||
|
|
090f4d876d | ||
|
|
5b575f962e | ||
|
|
8c3841d9a5 | ||
|
|
fd45309891 | ||
|
|
c4c86a3796 | ||
| 7456ead430 | |||
| f1fec2355c | |||
| fbf4337a83 | |||
| 8e969165b4 | |||
| 43feb4cef0 | |||
| a87f97ef53 | |||
| fdea8443fb | |||
| 6bb35d468f | |||
| e1b0e5e44d | |||
| edd044a1f8 | |||
| eeed646203 | |||
| eeb4a649c1 | |||
| 766fc296a1 | |||
| 8324f31972 | |||
| 9cac86d241 | |||
| dc4fbeb7ec | |||
| c1e42d6aa1 | |||
| e9da6f3120 | |||
| bad366d5ea | |||
| 9700d0bd9a | |||
| 819e716197 | |||
| da083c125c | |||
| 20f1058e9e | |||
| 5ea2dafc0e | |||
| d4c8046515 | |||
| b4183507a8 | |||
| 81ff6ea659 | |||
| 8b1c8dcb29 | |||
| e7b72ff95b | |||
| ba8cd46e75 |
@@ -2,10 +2,9 @@
|
|||||||
.gitea
|
.gitea
|
||||||
.next
|
.next
|
||||||
node_modules
|
node_modules
|
||||||
data/
|
data/*.db
|
||||||
*.db
|
data/*.db-shm
|
||||||
*.db-shm
|
data/*.db-wal
|
||||||
*.db-wal
|
|
||||||
.env*
|
.env*
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -4,26 +4,14 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-push:
|
build-push:
|
||||||
name: Build & Push
|
name: Build & Push
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name == 'push'
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Docker metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ vars.REGISTRY }}/${{ gitea.repository_owner }}/sixflagssupercalendar
|
|
||||||
tags: |
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
|
|
||||||
- name: Log in to Gitea registry
|
- name: Log in to Gitea registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -31,10 +19,18 @@ jobs:
|
|||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push web image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
target: web
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ vars.REGISTRY }}/${{ gitea.repository_owner }}/sixflagssupercalendar:web
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
|
- name: Build and push scraper image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
target: scraper
|
||||||
|
push: true
|
||||||
|
tags: ${{ vars.REGISTRY }}/${{ gitea.repository_owner }}/sixflagssupercalendar:scraper
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -21,13 +21,18 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# scraped data — local only, not committed
|
# scraped data — local only, not committed
|
||||||
/data/
|
/data/*.db
|
||||||
|
/data/*.db-shm
|
||||||
|
/data/*.db-wal
|
||||||
|
|
||||||
# env files
|
# env files
|
||||||
.env*
|
.env*
|
||||||
|
|||||||
63
Dockerfile
63
Dockerfile
@@ -1,4 +1,4 @@
|
|||||||
# Stage 1: Install all dependencies (dev included — scripts need tsx + playwright)
|
# Stage 1: Install all dependencies (dev included — scraper needs tsx + playwright)
|
||||||
FROM node:22-bookworm-slim AS deps
|
FROM node:22-bookworm-slim AS deps
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && \
|
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
@@ -11,47 +11,60 @@ FROM deps AS builder
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 3: Production runner
|
# ── web ──────────────────────────────────────────────────────────────────────
|
||||||
FROM node:22-bookworm-slim AS runner
|
# Minimal Next.js runner. No playwright, no tsx, no scripts.
|
||||||
|
# next build --output standalone bundles its own node_modules (incl. better-sqlite3).
|
||||||
|
FROM node:22-bookworm-slim AS web
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
# Store Playwright browser in a predictable path inside the image
|
|
||||||
ENV PLAYWRIGHT_BROWSERS_PATH=/app/.playwright
|
|
||||||
|
|
||||||
# Create non-root user before copying files so --chown works
|
|
||||||
RUN addgroup --system --gid 1001 nodejs && \
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
adduser --system --uid 1001 nextjs
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Copy Next.js standalone output
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
|
||||||
# Copy scripts + library source (needed for npm run discover/scrape via tsx)
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/tsconfig.json ./tsconfig.json
|
|
||||||
|
|
||||||
# Replace standalone's minimal node_modules with full deps
|
|
||||||
# (includes tsx, playwright, and all devDependencies)
|
|
||||||
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
|
|
||||||
|
|
||||||
# Install Playwright Chromium browser + all required system libraries.
|
|
||||||
# Runs as root so apt-get works; browser lands in PLAYWRIGHT_BROWSERS_PATH.
|
|
||||||
RUN npx playwright install --with-deps chromium && \
|
|
||||||
chown -R nextjs:nodejs /app/.playwright
|
|
||||||
|
|
||||||
# SQLite data directory — mount a named volume here for persistence
|
|
||||||
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
||||||
VOLUME ["/app/data"]
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|
||||||
|
# ── scraper ───────────────────────────────────────────────────────────────────
|
||||||
|
# Scraper-only image. No Next.js output. Runs on a nightly schedule via
|
||||||
|
# scripts/scrape-schedule.sh. Staleness windows are configurable via env vars:
|
||||||
|
# PARK_HOURS_STALENESS_HOURS (default: 72)
|
||||||
|
# COASTER_STALENESS_HOURS (default: 720 = 30 days)
|
||||||
|
FROM node:22-bookworm-slim AS scraper
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH=/app/.playwright
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/tests ./tests
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/tsconfig.json ./tsconfig.json
|
||||||
|
|
||||||
|
# Full node_modules — includes tsx, playwright, better-sqlite3, all devDeps
|
||||||
|
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Install Playwright Chromium + system libraries (runs as root, then fixes ownership)
|
||||||
|
RUN npx playwright install --with-deps chromium && \
|
||||||
|
chown -R nextjs:nodejs /app/.playwright
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
CMD ["sh", "/app/scripts/scrape-schedule.sh"]
|
||||||
|
|||||||
178
README.md
178
README.md
@@ -1,31 +1,48 @@
|
|||||||
# Six Flags Super Calendar
|
# Thoosie Calendar
|
||||||
|
|
||||||
A week-by-week calendar showing operating hours for all Six Flags Entertainment Group theme parks — including the former Cedar Fair parks. Data is scraped from the Six Flags internal API and stored locally in SQLite.
|
A week-by-week calendar showing operating hours for all Six Flags Entertainment Group theme parks — including the former Cedar Fair parks. Data is scraped from the Six Flags internal API and stored locally in SQLite. Click any park to see its full month calendar and live ride status with current wait times.
|
||||||
|
|
||||||
## Parks
|
## Parks
|
||||||
|
|
||||||
24 theme parks across the US, Canada, and Mexico:
|
24 theme parks across the US, Canada, and Mexico, grouped by region:
|
||||||
|
|
||||||
**Six Flags branded** — Great Adventure (NJ), Magic Mountain (CA), Great America (IL), Over Georgia, Over Texas, St. Louis, Fiesta Texas (TX), New England (MA), Discovery Kingdom (CA), Mexico, Great Escape (NY), Darien Lake (NY), Frontier City (OK)
|
| Region | Parks |
|
||||||
|
|--------|-------|
|
||||||
**Former Cedar Fair** — Cedar Point (OH), Knott's Berry Farm (CA), Canada's Wonderland (ON), Carowinds (NC), Kings Dominion (VA), Kings Island (OH), Valleyfair (MN), Worlds of Fun (MO), Michigan's Adventure (MI), Dorney Park (PA), California's Great America (CA)
|
| **Northeast** | Great Adventure (NJ), New England (MA), Great Escape (NY), Darien Lake (NY), Dorney Park (PA), Canada's Wonderland (ON) |
|
||||||
|
| **Southeast** | Over Georgia, Carowinds (NC), Kings Dominion (VA) |
|
||||||
|
| **Midwest** | Great America (IL), St. Louis (MO), Cedar Point (OH), Kings Island (OH), Valleyfair (MN), Worlds of Fun (MO), Michigan's Adventure (MI) |
|
||||||
|
| **Texas & South** | Over Texas, Fiesta Texas (TX), Frontier City (OK) |
|
||||||
|
| **West & International** | Magic Mountain (CA), Discovery Kingdom (CA), Knott's Berry Farm (CA), California's Great America (CA), Mexico |
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Next.js 15** (App Router, Server Components, standalone output)
|
- **Next.js 15** — App Router, Server Components, standalone output
|
||||||
- **Tailwind CSS v4** (`@theme {}` CSS variables, no config file)
|
- **Tailwind CSS v4** — `@theme {}` CSS variables, no config file
|
||||||
- **SQLite** via `better-sqlite3` — persisted in `/app/data/parks.db`
|
- **SQLite** via `better-sqlite3` — persisted in `/app/data/parks.db`
|
||||||
- **Playwright** — one-time headless browser run to discover each park's internal API ID
|
- **Playwright** — one-time headless browser run to discover each park's internal API ID
|
||||||
- **Six Flags CloudFront API** — `https://d18car1k0ff81h.cloudfront.net/operating-hours/park/{id}?date=YYYYMM`
|
- **Six Flags CloudFront API** — `https://d18car1k0ff81h.cloudfront.net/operating-hours/park/{id}?date=YYYYMM`
|
||||||
|
- **Queue-Times.com API** — live ride open/closed status and wait times, updated every 5 minutes
|
||||||
|
|
||||||
|
## Ride Status
|
||||||
|
|
||||||
|
The park detail page shows ride open/closed status using a two-tier approach:
|
||||||
|
|
||||||
|
1. **Live data (Queue-Times.com)** — when a park is operating, ride status and wait times are fetched from the [Queue-Times.com API](https://queue-times.com/en-US/pages/api) and cached for 5 minutes. All 24 parks are mapped. Displays a **Live** badge with per-ride wait times.
|
||||||
|
|
||||||
|
2. **Schedule fallback (Six Flags API)** — the Six Flags operating-hours API drops the current day from its response once a park opens. When Queue-Times data is unavailable, the app falls back to the nearest upcoming date from the Six Flags schedule API as an approximation.
|
||||||
|
|
||||||
|
### Roller Coaster Filter
|
||||||
|
|
||||||
|
When live data is shown, a **Coasters only** toggle appears if roller coaster data has been populated for that park. Coaster lists are sourced from [RCDB](https://rcdb.com) and stored in `data/park-meta.json`. To populate them:
|
||||||
|
|
||||||
|
1. Open `data/park-meta.json` and set `rcdb_id` for each park to the numeric RCDB park ID (visible in the URL: `https://rcdb.com/4529.htm` → `4529`).
|
||||||
|
2. Run `npm run scrape` — coaster lists are fetched from RCDB and stored in the JSON file. They refresh automatically every 30 days on subsequent scrapes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
### Prerequisites
|
**Prerequisites:** Node.js 22+, npm
|
||||||
|
|
||||||
- Node.js 22+
|
|
||||||
- npm
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
@@ -40,98 +57,133 @@ Run once to discover each park's internal API ID (opens a headless browser per p
|
|||||||
npm run discover
|
npm run discover
|
||||||
```
|
```
|
||||||
|
|
||||||
Then scrape operating hours for the full year:
|
Scrape operating hours for the full year:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run scrape
|
npm run scrape
|
||||||
```
|
```
|
||||||
|
|
||||||
To force a full re-scrape (ignores the 7-day staleness window):
|
Force a full re-scrape (ignores the staleness window):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run scrape:force
|
npm run scrape:force
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Debug a specific park + date
|
||||||
|
|
||||||
|
Inspect raw API data and parsed output for any park and date:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run debug -- --park kingsisland --date 2026-06-15
|
||||||
|
```
|
||||||
|
|
||||||
|
Output is printed to the terminal and saved to `debug/{parkId}_{date}.txt`.
|
||||||
|
|
||||||
|
### Run tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
### Run the dev server
|
### Run the dev server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000). Navigate weeks with the `←` / `→` buttons or pass `?week=YYYY-MM-DD` directly.
|
Open [http://localhost:3000](http://localhost:3000). Navigate weeks with the `←` / `→` buttons, or pass `?week=YYYY-MM-DD` directly. Click any park name to open its detail page.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### Docker (standalone)
|
The app ships as two separate Docker images that share a named volume for the SQLite database:
|
||||||
|
|
||||||
The app uses Next.js standalone output. The SQLite database is stored in a Docker volume at `/app/data`.
|
| Image | Tag | Purpose |
|
||||||
|
|-------|-----|---------|
|
||||||
|
| Next.js web server | `:web` | Reads DB, serves content. No scraping tools. |
|
||||||
|
| Scraper + scheduler | `:scraper` | Nightly data refresh. No web server. |
|
||||||
|
|
||||||
#### Run
|
Images are built and pushed automatically by CI on every push to `main`.
|
||||||
|
|
||||||
|
### First-time setup
|
||||||
|
|
||||||
|
**1. Pull the images**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull gitea.thewrightserver.net/josh/sixflagssupercalendar:web
|
||||||
|
docker pull gitea.thewrightserver.net/josh/sixflagssupercalendar:scraper
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Discover park API IDs**
|
||||||
|
|
||||||
|
This one-time step opens a headless browser for each park to find its internal Six Flags API ID. Run it against the scraper image so Playwright is available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v root_park_data:/app/data \
|
||||||
|
gitea.thewrightserver.net/josh/sixflagssupercalendar:scraper \
|
||||||
|
npm run discover
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Set RCDB IDs for the coaster filter**
|
||||||
|
|
||||||
|
Open `data/park-meta.json` in the Docker volume and set `rcdb_id` for each park to the numeric ID from the RCDB URL (e.g. `https://rcdb.com/4529.htm` → `4529`). You can curl it directly from the repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -o /var/lib/docker/volumes/root_park_data/_data/park-meta.json \
|
||||||
|
https://gitea.thewrightserver.net/josh/SixFlagsSuperCalendar/raw/branch/main/data/park-meta.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Run the initial scrape**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v root_park_data:/app/data \
|
||||||
|
gitea.thewrightserver.net/josh/sixflagssupercalendar:scraper \
|
||||||
|
npm run scrape
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Start services**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Seed the database inside the container
|
Both services start. The scraper runs nightly at 3 AM (container timezone, set via `TZ`).
|
||||||
|
|
||||||
The production image includes Playwright and Chromium, so discovery and scraping can be run directly against the running container's volume.
|
### Updating
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Discover API IDs for all parks (one-time, opens headless browser per park)
|
docker compose pull && docker compose up -d
|
||||||
docker compose exec web npm run discover
|
|
||||||
|
|
||||||
# Scrape operating hours for the full year
|
|
||||||
docker compose exec web npm run scrape
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Or as one-off containers against the named volume:
|
### Scraper environment variables
|
||||||
|
|
||||||
|
Set these in `docker-compose.yml` under the `scraper` service to override defaults:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `TZ` | `UTC` | Timezone for the nightly 3 AM run (e.g. `America/New_York`) |
|
||||||
|
| `PARK_HOURS_STALENESS_HOURS` | `72` | Hours before park schedule data is re-fetched |
|
||||||
|
| `COASTER_STALENESS_HOURS` | `720` | Hours before RCDB coaster lists are re-fetched (720 = 30 days) |
|
||||||
|
|
||||||
|
### Manual scrape
|
||||||
|
|
||||||
|
To trigger a scrape outside the nightly schedule:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm -v sixflagssupercalendar_park_data:/app/data \
|
docker compose exec scraper npm run scrape
|
||||||
gitea.thewrightserver.net/josh/sixflagssupercalendar:latest \
|
|
||||||
npm run discover
|
|
||||||
|
|
||||||
docker run --rm -v sixflagssupercalendar_park_data:/app/data \
|
|
||||||
gitea.thewrightserver.net/josh/sixflagssupercalendar:latest \
|
|
||||||
npm run scrape
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Force re-scrape of all data (ignores staleness):
|
||||||
|
|
||||||
### CI/CD (Gitea Actions)
|
|
||||||
|
|
||||||
The pipeline is defined at [`.gitea/workflows/deploy.yml`](.gitea/workflows/deploy.yml).
|
|
||||||
|
|
||||||
**Trigger:** Push to `main`
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Checkout code
|
|
||||||
2. Log in to the Gitea container registry
|
|
||||||
3. Build and tag the image as `:latest` and `:<short-sha>`
|
|
||||||
4. Push both tags
|
|
||||||
|
|
||||||
#### Required configuration in Gitea
|
|
||||||
|
|
||||||
| Type | Name | Value |
|
|
||||||
|------|------|-------|
|
|
||||||
| Variable | `REGISTRY` | Registry hostname — `gitea.thewrightserver.net` |
|
|
||||||
| Secret | `REGISTRY_TOKEN` | A Gitea access token with `package:write` scope |
|
|
||||||
|
|
||||||
Set these under **Repository → Settings → Actions → Variables / Secrets**.
|
|
||||||
|
|
||||||
#### Upstream remote
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git remote add origin https://gitea.thewrightserver.net/josh/SixFlagsSuperCalendar.git
|
docker compose exec scraper npm run scrape:force
|
||||||
git push -u origin main
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data Refresh
|
## Data Refresh
|
||||||
|
|
||||||
The scrape job skips any park+month combination scraped within the last 7 days. To keep data current, run `npm run scrape` (or `scrape:force`) on a schedule — weekly is sufficient for a season calendar.
|
The scraper skips any park + month already scraped within the staleness window (`PARK_HOURS_STALENESS_HOURS`, default 72h). Past dates are never overwritten — once a day occurs, the API stops returning data for it, so the record written when it was a future date is preserved forever. The nightly scraper handles refresh automatically.
|
||||||
|
|
||||||
Parks and months not yet in the database show a `—` placeholder in the UI. Parks with no hours data on a given day show "Closed".
|
Roller coaster lists (from RCDB) are refreshed per `COASTER_STALENESS_HOURS` (default 720h = 30 days) for parks with a configured `rcdb_id`.
|
||||||
|
|||||||
114
app/globals.css
114
app/globals.css
@@ -1,49 +1,61 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* ── Backgrounds ─────────────────────────────────────────────────────────── */
|
/* ── Backgrounds — deep neutral dark, no purple tint ─────────────────────── */
|
||||||
--color-bg: #0c1220;
|
--color-bg: #111111;
|
||||||
--color-surface: #141c2e;
|
--color-surface: #1c1c1c;
|
||||||
--color-surface-2: #1c2640;
|
--color-surface-2: #242424;
|
||||||
--color-surface-hover: #222e4a;
|
--color-surface-hover: #2c2c2c;
|
||||||
--color-border: #1f2d45;
|
--color-border: #333333;
|
||||||
--color-border-subtle: #172035;
|
--color-border-subtle: #272727;
|
||||||
|
|
||||||
/* ── Text ────────────────────────────────────────────────────────────────── */
|
/* ── Text — clean white, no tint ─────────────────────────────────────────── */
|
||||||
--color-text: #f1f5f9;
|
--color-text: #f5f5f5;
|
||||||
--color-text-secondary: #94a3b8;
|
--color-text-secondary: #b0b0b0;
|
||||||
--color-text-muted: #64748b;
|
--color-text-muted: #737373;
|
||||||
--color-text-dim: #475569;
|
--color-text-dim: #4a4a4a;
|
||||||
|
|
||||||
/* ── Warm accent (Today / active states) ─────────────────────────────────── */
|
/* ── Hot pink accent — neon sign energy ──────────────────────────────────── */
|
||||||
--color-accent: #f59e0b;
|
--color-accent: #ff4d8d;
|
||||||
--color-accent-hover: #d97706;
|
--color-accent-hover: #e6006e;
|
||||||
--color-accent-text: #fef3c7;
|
--color-accent-text: #fff0f7;
|
||||||
--color-accent-muted: #78350f;
|
--color-accent-muted: #3d0f22;
|
||||||
|
|
||||||
/* ── Open (green) ────────────────────────────────────────────────────────── */
|
/* ── Open — electric lime green (go!) ────────────────────────────────────── */
|
||||||
--color-open-bg: #052e16;
|
--color-open-bg: #0a1a0d;
|
||||||
--color-open-border: #16a34a;
|
--color-open-border: #22c55e;
|
||||||
--color-open-text: #4ade80;
|
--color-open-text: #4ade80;
|
||||||
--color-open-hours: #dcfce7;
|
--color-open-hours: #bbf7d0;
|
||||||
|
|
||||||
/* ── Passholder preview (purple) ─────────────────────────────────────────── */
|
/* ── Weather delay — blue (open by schedule but all rides closed) ───────── */
|
||||||
--color-ph-bg: #1e0f2e;
|
--color-weather-bg: #0a1020;
|
||||||
--color-ph-border: #7e22ce;
|
--color-weather-border: #3b82f6;
|
||||||
--color-ph-hours: #e9d5ff;
|
--color-weather-text: #60a5fa;
|
||||||
--color-ph-label: #c084fc;
|
--color-weather-hours: #bfdbfe;
|
||||||
|
|
||||||
/* ── Today column (amber instead of cold blue) ───────────────────────────── */
|
/* ── Closing — amber (post-close buffer, rides still winding down) ───────── */
|
||||||
--color-today-bg: #1c1a0e;
|
--color-closing-bg: #1a1100;
|
||||||
--color-today-border: #f59e0b;
|
--color-closing-border: #d97706;
|
||||||
--color-today-text: #fde68a;
|
--color-closing-text: #fbbf24;
|
||||||
|
--color-closing-hours: #fde68a;
|
||||||
|
|
||||||
/* ── Weekend header ──────────────────────────────────────────────────────── */
|
/* ── Passholder preview — vivid cyan ─────────────────────────────────────── */
|
||||||
--color-weekend-header: #141f35;
|
--color-ph-bg: #051518;
|
||||||
|
--color-ph-border: #22d3ee;
|
||||||
|
--color-ph-hours: #cffafe;
|
||||||
|
--color-ph-label: #67e8f9;
|
||||||
|
|
||||||
|
/* ── Today — vivid yellow, unmissable ────────────────────────────────────── */
|
||||||
|
--color-today-bg: #1a1800;
|
||||||
|
--color-today-border: #facc15;
|
||||||
|
--color-today-text: #fef08a;
|
||||||
|
|
||||||
|
/* ── Weekend — barely-there dark tint ───────────────────────────────────────*/
|
||||||
|
--color-weekend-header: #181818;
|
||||||
|
|
||||||
/* ── Region header ───────────────────────────────────────────────────────── */
|
/* ── Region header ───────────────────────────────────────────────────────── */
|
||||||
--color-region-bg: #0e1628;
|
--color-region-bg: #161616;
|
||||||
--color-region-accent: #334155;
|
--color-region-accent: #ff4d8d;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -64,14 +76,14 @@
|
|||||||
height: 6px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: var(--color-bg);
|
background: var(--color-surface-2);
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: var(--color-border);
|
background: var(--color-border);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--color-text-muted);
|
background: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Sticky column shadow when scrolling ─────────────────────────────────── */
|
/* ── Sticky column shadow when scrolling ─────────────────────────────────── */
|
||||||
@@ -80,20 +92,34 @@
|
|||||||
clip-path: inset(0 -16px 0 0);
|
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 hover ────────────────────────────────────────────────── */
|
||||||
.park-name-link {
|
.park-name-link {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
transition: color 120ms ease;
|
transition: background 150ms ease;
|
||||||
}
|
}
|
||||||
.park-name-link:hover {
|
.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 ───────────────────────────────────────── */
|
/* ── Pulse animation for skeleton ───────────────────────────────────────── */
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Six Flags Calendar",
|
title: "Thoosie Calendar",
|
||||||
description: "Theme park operating calendars at a glance",
|
description: "Theme park operating hours and live ride status at a glance",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
|||||||
197
app/page.tsx
197
app/page.tsx
@@ -1,10 +1,12 @@
|
|||||||
import { WeekCalendar } from "@/components/WeekCalendar";
|
import { HomePageClient } from "@/components/HomePageClient";
|
||||||
import { MobileCardList } from "@/components/MobileCardList";
|
import { PARKS } from "@/lib/parks";
|
||||||
import { WeekNav } from "@/components/WeekNav";
|
import { openDb, getDateRange, getApiId } from "@/lib/db";
|
||||||
import { Legend } from "@/components/Legend";
|
import { getTodayLocal, isWithinOperatingWindow, getOperatingStatus } from "@/lib/env";
|
||||||
import { EmptyState } from "@/components/EmptyState";
|
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
||||||
import { PARKS, groupByRegion } from "@/lib/parks";
|
import { fetchToday } from "@/lib/scrapers/sixflags";
|
||||||
import { openDb, getDateRange } from "@/lib/db";
|
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
|
||||||
|
import { readParkMeta, getCoasterSet } from "@/lib/park-meta";
|
||||||
|
import type { DayData } from "@/lib/db";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
searchParams: Promise<{ week?: string }>;
|
searchParams: Promise<{ week?: string }>;
|
||||||
@@ -18,9 +20,10 @@ function getWeekStart(param: string | undefined): string {
|
|||||||
return d.toISOString().slice(0, 10);
|
return d.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const today = new Date();
|
const todayIso = getTodayLocal();
|
||||||
today.setDate(today.getDate() - today.getDay());
|
const d = new Date(todayIso + "T00:00:00");
|
||||||
return today.toISOString().slice(0, 10);
|
d.setDate(d.getDate() - d.getDay());
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWeekDates(sundayIso: string): string[] {
|
function getWeekDates(sundayIso: string): string[] {
|
||||||
@@ -32,9 +35,10 @@ function getWeekDates(sundayIso: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getCurrentWeekStart(): string {
|
function getCurrentWeekStart(): string {
|
||||||
const today = new Date();
|
const todayIso = getTodayLocal();
|
||||||
today.setDate(today.getDate() - today.getDay());
|
const d = new Date(todayIso + "T00:00:00");
|
||||||
return today.toISOString().slice(0, 10);
|
d.setDate(d.getDate() - d.getDay());
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function HomePage({ searchParams }: PageProps) {
|
export default async function HomePage({ searchParams }: PageProps) {
|
||||||
@@ -42,11 +46,36 @@ export default async function HomePage({ searchParams }: PageProps) {
|
|||||||
const weekStart = getWeekStart(params.week);
|
const weekStart = getWeekStart(params.week);
|
||||||
const weekDates = getWeekDates(weekStart);
|
const weekDates = getWeekDates(weekStart);
|
||||||
const endDate = weekDates[6];
|
const endDate = weekDates[6];
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = getTodayLocal();
|
||||||
const isCurrentWeek = weekStart === getCurrentWeekStart();
|
const isCurrentWeek = weekStart === getCurrentWeekStart();
|
||||||
|
|
||||||
const db = openDb();
|
const db = openDb();
|
||||||
const data = getDateRange(db, weekStart, endDate);
|
const data = getDateRange(db, weekStart, endDate);
|
||||||
|
|
||||||
|
// Merge live today data from the Six Flags API (dateless endpoint, 5-min ISR cache).
|
||||||
|
// This ensures weather delays, early closures, and hour changes surface within 5 minutes
|
||||||
|
// without waiting for the next scheduled scrape. Only fetched when viewing the current week.
|
||||||
|
if (weekDates.includes(today)) {
|
||||||
|
const todayResults = await Promise.all(
|
||||||
|
PARKS.map(async (p) => {
|
||||||
|
const apiId = getApiId(db, p.id);
|
||||||
|
if (!apiId) return null;
|
||||||
|
const live = await fetchToday(apiId, 300); // 5-min ISR cache
|
||||||
|
return live ? { parkId: p.id, live } : null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
for (const result of todayResults) {
|
||||||
|
if (!result) continue;
|
||||||
|
const { parkId, live } = result;
|
||||||
|
if (!data[parkId]) data[parkId] = {};
|
||||||
|
data[parkId][today] = {
|
||||||
|
isOpen: live.isOpen,
|
||||||
|
hoursLabel: live.hoursLabel ?? null,
|
||||||
|
specialType: live.specialType ?? null,
|
||||||
|
} satisfies DayData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
|
|
||||||
const scrapedCount = Object.values(data).reduce(
|
const scrapedCount = Object.values(data).reduce(
|
||||||
@@ -54,98 +83,68 @@ export default async function HomePage({ searchParams }: PageProps) {
|
|||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
const visibleParks = PARKS.filter((park) =>
|
// Always fetch both ride and coaster counts — the client decides which to display.
|
||||||
weekDates.some((date) => data[park.id]?.[date]?.isOpen)
|
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 (
|
return (
|
||||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
<HomePageClient
|
||||||
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
|
||||||
<header style={{
|
|
||||||
position: "sticky",
|
|
||||||
top: 0,
|
|
||||||
zIndex: 20,
|
|
||||||
background: "var(--color-bg)",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
}}>
|
|
||||||
{/* Row 1: Title + park count */}
|
|
||||||
<div style={{
|
|
||||||
padding: "12px 24px 10px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: 12,
|
|
||||||
}}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: "1.1rem",
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--color-text)",
|
|
||||||
letterSpacing: "-0.02em",
|
|
||||||
}}>
|
|
||||||
Six Flags Calendar
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span style={{
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
borderRadius: 20,
|
|
||||||
padding: "3px 10px",
|
|
||||||
fontSize: "0.7rem",
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
fontWeight: 500,
|
|
||||||
}}>
|
|
||||||
{visibleParks.length} of {PARKS.length} parks open
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 2: Week nav + legend */}
|
|
||||||
<div style={{
|
|
||||||
padding: "8px 24px 10px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: 16,
|
|
||||||
borderTop: "1px solid var(--color-border-subtle)",
|
|
||||||
}}>
|
|
||||||
<WeekNav
|
|
||||||
weekStart={weekStart}
|
weekStart={weekStart}
|
||||||
weekDates={weekDates}
|
weekDates={weekDates}
|
||||||
isCurrentWeek={isCurrentWeek}
|
|
||||||
/>
|
|
||||||
<Legend />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* ── Main content ───────────────────────────────────────────────────── */}
|
|
||||||
<main style={{ padding: "0 24px 48px" }}>
|
|
||||||
{scrapedCount === 0 ? (
|
|
||||||
<EmptyState />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Mobile: card list (hidden on lg+) */}
|
|
||||||
<div className="lg:hidden">
|
|
||||||
<MobileCardList
|
|
||||||
grouped={grouped}
|
|
||||||
weekDates={weekDates}
|
|
||||||
data={data}
|
|
||||||
today={today}
|
today={today}
|
||||||
/>
|
isCurrentWeek={isCurrentWeek}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: week table (hidden below lg) */}
|
|
||||||
<div className="hidden lg:block">
|
|
||||||
<WeekCalendar
|
|
||||||
parks={visibleParks}
|
|
||||||
weekDates={weekDates}
|
|
||||||
data={data}
|
data={data}
|
||||||
grouped={grouped}
|
rideCounts={rideCounts}
|
||||||
|
coasterCounts={coasterCounts}
|
||||||
|
openParkIds={openParkIds}
|
||||||
|
closingParkIds={closingParkIds}
|
||||||
|
weatherDelayParkIds={weatherDelayParkIds}
|
||||||
|
hasCoasterData={hasCoasterData}
|
||||||
|
scrapedCount={scrapedCount}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { BackToCalendarLink } from "@/components/BackToCalendarLink";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { PARK_MAP } from "@/lib/parks";
|
import { PARK_MAP } from "@/lib/parks";
|
||||||
import { openDb, getParkMonthData, getApiId } from "@/lib/db";
|
import { openDb, getParkMonthData, getApiId } from "@/lib/db";
|
||||||
import { scrapeRidesForDay } from "@/lib/scrapers/sixflags";
|
import { scrapeRidesForDay } from "@/lib/scrapers/sixflags";
|
||||||
|
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
||||||
|
import { 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 { ParkMonthCalendar } from "@/components/ParkMonthCalendar";
|
||||||
|
import { LiveRidePanel } from "@/components/LiveRidePanel";
|
||||||
import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags";
|
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 {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -18,8 +26,8 @@ function parseMonthParam(param: string | undefined): { year: number; month: numb
|
|||||||
return { year: y, month: m };
|
return { year: y, month: m };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const now = new Date();
|
const [y, m] = getTodayLocal().split("-").map(Number);
|
||||||
return { year: now.getFullYear(), month: now.getMonth() + 1 };
|
return { year: y, month: m };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ParkPage({ params, searchParams }: PageProps) {
|
export default async function ParkPage({ params, searchParams }: PageProps) {
|
||||||
@@ -29,7 +37,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
|||||||
const park = PARK_MAP.get(id);
|
const park = PARK_MAP.get(id);
|
||||||
if (!park) notFound();
|
if (!park) notFound();
|
||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = getTodayLocal();
|
||||||
const { year, month } = parseMonthParam(monthParam);
|
const { year, month } = parseMonthParam(monthParam);
|
||||||
|
|
||||||
const db = openDb();
|
const db = openDb();
|
||||||
@@ -37,16 +45,52 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
|||||||
const apiId = getApiId(db, id);
|
const apiId = getApiId(db, id);
|
||||||
db.close();
|
db.close();
|
||||||
|
|
||||||
// Fetch live ride data — cached 1h via Next.js ISR.
|
// Prefer live today data from the Six Flags API (5-min ISR cache) so that
|
||||||
// Note: the API drops today's date from its response (only returns future dates),
|
// weather delays and hour changes surface immediately rather than showing
|
||||||
// so scrapeRidesForDay may fall back to the nearest upcoming date.
|
// stale DB values. Fall back to DB if the API call fails.
|
||||||
|
const liveToday = apiId !== null ? await fetchToday(apiId, 300).catch(() => null) : null;
|
||||||
|
const todayData = liveToday
|
||||||
|
? { isOpen: liveToday.isOpen, hoursLabel: liveToday.hoursLabel ?? null, specialType: liveToday.specialType ?? null }
|
||||||
|
: monthData[today];
|
||||||
|
const parkOpenToday = todayData?.isOpen && todayData?.hoursLabel;
|
||||||
|
|
||||||
|
// ── Ride data: try live Queue-Times first, fall back to schedule ──────────
|
||||||
|
const queueTimesId = QUEUE_TIMES_IDS[id];
|
||||||
|
const parkMeta = readParkMeta();
|
||||||
|
const coasterSet = getCoasterSet(id, parkMeta);
|
||||||
|
|
||||||
|
let liveRides: LiveRidesResult | null = null;
|
||||||
let ridesResult: RidesFetchResult | null = null;
|
let ridesResult: RidesFetchResult | null = null;
|
||||||
if (apiId !== null) {
|
|
||||||
ridesResult = await scrapeRidesForDay(apiId, today);
|
// Determine if we're within the 1h-before-open to 1h-after-close window.
|
||||||
|
const withinWindow = todayData?.hoursLabel
|
||||||
|
? isWithinOperatingWindow(todayData.hoursLabel, park.timezone)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (queueTimesId) {
|
||||||
|
const raw = await fetchLiveRides(queueTimesId, coasterSet);
|
||||||
|
if (raw) {
|
||||||
|
// Outside the window: show the ride list but force all rides closed
|
||||||
|
liveRides = withinWindow
|
||||||
|
? raw
|
||||||
|
: {
|
||||||
|
...raw,
|
||||||
|
rides: raw.rides.map((r) => ({ ...r, isOpen: false, waitMinutes: 0 })),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const todayData = monthData[today];
|
// Weather delay: park is within operating hours but queue-times shows 0 open rides
|
||||||
const parkOpenToday = todayData?.isOpen && todayData?.hoursLabel;
|
const isWeatherDelay =
|
||||||
|
withinWindow &&
|
||||||
|
liveRides !== null &&
|
||||||
|
liveRides.rides.length > 0 &&
|
||||||
|
liveRides.rides.every((r) => !r.isOpen);
|
||||||
|
|
||||||
|
// Only hit the schedule API as a fallback when Queue-Times live data is unavailable.
|
||||||
|
if (!liveRides && apiId !== null) {
|
||||||
|
ridesResult = await scrapeRidesForDay(apiId, today);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
||||||
@@ -62,19 +106,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 16,
|
gap: 16,
|
||||||
}}>
|
}}>
|
||||||
<Link href="/" style={{
|
<BackToCalendarLink />
|
||||||
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>
|
|
||||||
<div style={{ width: 1, height: 16, background: "var(--color-border)" }} />
|
<div style={{ width: 1, height: 16, background: "var(--color-border)" }} />
|
||||||
<span style={{ fontSize: "0.9rem", fontWeight: 600, color: "var(--color-text)", letterSpacing: "-0.01em" }}>
|
<span style={{ fontSize: "0.9rem", fontWeight: 600, color: "var(--color-text)", letterSpacing: "-0.01em" }}>
|
||||||
{park.name}
|
{park.name}
|
||||||
@@ -84,7 +116,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main style={{ padding: "24px", maxWidth: 960, margin: "0 auto", display: "flex", flexDirection: "column", gap: 40 }}>
|
<main style={{ padding: "24px 32px", maxWidth: 1280, margin: "0 auto", display: "flex", flexDirection: "column", gap: 40 }}>
|
||||||
|
|
||||||
{/* ── Month Calendar ───────────────────────────────────────────────── */}
|
{/* ── Month Calendar ───────────────────────────────────────────────── */}
|
||||||
<section>
|
<section>
|
||||||
@@ -94,25 +126,58 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
|||||||
month={month}
|
month={month}
|
||||||
monthData={monthData}
|
monthData={monthData}
|
||||||
today={today}
|
today={today}
|
||||||
|
timezone={park.timezone}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Ride Status ─────────────────────────────────────────────────── */}
|
{/* ── Ride Status ─────────────────────────────────────────────────── */}
|
||||||
<section>
|
<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
|
Rides
|
||||||
|
{liveRides ? (
|
||||||
|
<LiveBadge />
|
||||||
|
) : ridesResult && !ridesResult.isExact ? (
|
||||||
<span style={{ fontSize: "0.72rem", fontWeight: 400, color: "var(--color-text-muted)", marginLeft: 8 }}>
|
<span style={{ fontSize: "0.72rem", fontWeight: 400, color: "var(--color-text-muted)", marginLeft: 8 }}>
|
||||||
{ridesResult && !ridesResult.isExact
|
{formatShortDate(ridesResult.dataDate)}
|
||||||
? formatShortDate(ridesResult.dataDate)
|
|
||||||
: "Today"}
|
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: "0.72rem", fontWeight: 400, color: "var(--color-text-muted)", marginLeft: 8 }}>
|
||||||
|
Today
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</SectionHeading>
|
</SectionHeading>
|
||||||
|
|
||||||
|
{liveRides ? (
|
||||||
|
<LiveRidePanel
|
||||||
|
liveRides={liveRides}
|
||||||
|
parkOpenToday={!!parkOpenToday}
|
||||||
|
isWeatherDelay={isWeatherDelay}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<RideList
|
<RideList
|
||||||
ridesResult={ridesResult}
|
ridesResult={ridesResult}
|
||||||
parkOpenToday={!!parkOpenToday}
|
parkOpenToday={!!parkOpenToday}
|
||||||
apiIdMissing={apiId === null}
|
apiIdMissing={apiId === null && !queueTimesId}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,12 +194,12 @@ function formatShortDate(iso: string): string {
|
|||||||
|
|
||||||
// ── Sub-components ─────────────────────────────────────────────────────────
|
// ── Sub-components ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SectionHeading({ children }: { children: React.ReactNode }) {
|
function SectionHeading({ children, aside }: { children: React.ReactNode; aside?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "baseline",
|
alignItems: "center",
|
||||||
gap: 0,
|
justifyContent: "space-between",
|
||||||
marginBottom: 14,
|
marginBottom: 14,
|
||||||
paddingBottom: 10,
|
paddingBottom: 10,
|
||||||
borderBottom: "1px solid var(--color-border)",
|
borderBottom: "1px solid var(--color-border)",
|
||||||
@@ -146,13 +211,48 @@ function SectionHeading({ children }: { children: React.ReactNode }) {
|
|||||||
letterSpacing: "0.04em",
|
letterSpacing: "0.04em",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
margin: 0,
|
margin: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</h2>
|
</h2>
|
||||||
|
{aside}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LiveBadge() {
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
marginLeft: 10,
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 20,
|
||||||
|
background: "var(--color-open-bg)",
|
||||||
|
border: "1px solid var(--color-open-border)",
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--color-open-text)",
|
||||||
|
verticalAlign: "middle",
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: 5,
|
||||||
|
height: 5,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--color-open-text)",
|
||||||
|
display: "inline-block",
|
||||||
|
}} />
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schedule ride list (Six Flags operating-hours API fallback) ────────────
|
||||||
|
|
||||||
function RideList({
|
function RideList({
|
||||||
ridesResult,
|
ridesResult,
|
||||||
parkOpenToday,
|
parkOpenToday,
|
||||||
@@ -235,9 +335,6 @@ function RideList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RideRow({ ride, parkHoursLabel }: { ride: RideStatus; parkHoursLabel?: string }) {
|
function RideRow({ ride, parkHoursLabel }: { ride: RideStatus; parkHoursLabel?: string }) {
|
||||||
// Only show the ride's hours when they differ from the park's overall hours.
|
|
||||||
// This avoids repeating "10am – 6pm" on every single row when that's the
|
|
||||||
// default — but surfaces exceptions like "11am – 4pm" for Safari tours, etc.
|
|
||||||
const showHours = ride.isOpen && ride.hoursLabel && ride.hoursLabel !== parkHoursLabel;
|
const showHours = ride.isOpen && ride.hoursLabel && ride.hoursLabel !== parkHoursLabel;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -260,7 +357,7 @@ function RideRow({ ride, parkHoursLabel }: { ride: RideStatus; parkHoursLabel?:
|
|||||||
background: ride.isOpen ? "var(--color-open-text)" : "var(--color-text-dim)",
|
background: ride.isOpen ? "var(--color-open-text)" : "var(--color-text-dim)",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}} />
|
}} />
|
||||||
<span style={{
|
<span title={ride.name} style={{
|
||||||
fontSize: "0.8rem",
|
fontSize: "0.8rem",
|
||||||
color: ride.isOpen ? "var(--color-text)" : "var(--color-text-muted)",
|
color: ride.isOpen ? "var(--color-text)" : "var(--color-text-muted)",
|
||||||
fontWeight: ride.isOpen ? 500 : 400,
|
fontWeight: ride.isOpen ? 500 : 400,
|
||||||
|
|||||||
31
components/BackToCalendarLink.tsx
Normal file
31
components/BackToCalendarLink.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,21 +10,11 @@ export function EmptyState() {
|
|||||||
color: "var(--color-text-muted)",
|
color: "var(--color-text-muted)",
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: "2rem" }}>📅</div>
|
<div style={{ fontSize: "2rem" }}>📅</div>
|
||||||
<div style={{ fontSize: "1rem", fontWeight: 600, color: "var(--color-text)" }}>No data scraped yet</div>
|
<div style={{ fontSize: "1rem", fontWeight: 600, color: "var(--color-text)" }}>Schedule not available yet</div>
|
||||||
<div style={{ fontSize: "0.85rem", textAlign: "center", lineHeight: 1.6 }}>
|
<div style={{ fontSize: "0.85rem", textAlign: "center", lineHeight: 1.6 }}>
|
||||||
Run the following to populate the calendar:
|
Park hours for this period haven't been published yet.<br />
|
||||||
|
Check back closer to your visit.
|
||||||
</div>
|
</div>
|
||||||
<pre style={{
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: "12px 20px",
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
color: "var(--color-text)",
|
|
||||||
lineHeight: 1.8,
|
|
||||||
}}>
|
|
||||||
npm run discover{"\n"}npm run scrape
|
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
263
components/HomePageClient.tsx
Normal file
263
components/HomePageClient.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
components/LiveRidePanel.tsx
Normal file
201
components/LiveRidePanel.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import type { LiveRidesResult, LiveRide } from "@/lib/scrapers/queuetimes";
|
||||||
|
|
||||||
|
interface LiveRidePanelProps {
|
||||||
|
liveRides: LiveRidesResult;
|
||||||
|
parkOpenToday: boolean;
|
||||||
|
isWeatherDelay?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: LiveRidePanelProps) {
|
||||||
|
const { rides } = liveRides;
|
||||||
|
const hasCoasters = rides.some((r) => r.isCoaster);
|
||||||
|
const [coastersOnly, setCoastersOnly] = useState(false);
|
||||||
|
|
||||||
|
// Pre-select coaster filter if Coaster Mode is enabled on the homepage.
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasCoasters && localStorage.getItem("coasterMode") === "true") {
|
||||||
|
setCoastersOnly(true);
|
||||||
|
}
|
||||||
|
}, [hasCoasters]);
|
||||||
|
|
||||||
|
const visible = coastersOnly ? rides.filter((r) => r.isCoaster) : rides;
|
||||||
|
const openRides = visible.filter((r) => r.isOpen);
|
||||||
|
const closedRides = visible.filter((r) => !r.isOpen);
|
||||||
|
const anyOpen = openRides.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* ── Toolbar: summary + coaster toggle ────────────────────────────── */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
marginBottom: 16,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}>
|
||||||
|
{/* Open count badge */}
|
||||||
|
{anyOpen ? (
|
||||||
|
<div style={{
|
||||||
|
background: "var(--color-open-bg)",
|
||||||
|
border: "1px solid var(--color-open-border)",
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: "4px 12px",
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-open-hours)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{openRides.length} open
|
||||||
|
</div>
|
||||||
|
) : isWeatherDelay ? (
|
||||||
|
<div style={{
|
||||||
|
background: "var(--color-weather-bg)",
|
||||||
|
border: "1px solid var(--color-weather-border)",
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: "4px 12px",
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-weather-text)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
⛈ Weather Delay — all rides currently closed
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: "4px 12px",
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{parkOpenToday ? "Not open yet — check back soon" : "No rides open"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Closed count badge — always shown when there are closed rides */}
|
||||||
|
{closedRides.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: "4px 12px",
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{closedRides.length} {anyOpen ? "closed / down" : "rides total"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Coaster toggle — only shown when the park has categorised coasters */}
|
||||||
|
{hasCoasters && (
|
||||||
|
<button
|
||||||
|
onClick={() => setCoastersOnly((v) => !v)}
|
||||||
|
style={{
|
||||||
|
marginLeft: "auto",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
padding: "4px 12px",
|
||||||
|
borderRadius: 20,
|
||||||
|
border: coastersOnly
|
||||||
|
? "1px solid var(--color-accent)"
|
||||||
|
: "1px solid var(--color-border)",
|
||||||
|
background: coastersOnly
|
||||||
|
? "var(--color-accent-muted)"
|
||||||
|
: "var(--color-surface)",
|
||||||
|
color: coastersOnly
|
||||||
|
? "var(--color-accent)"
|
||||||
|
: "var(--color-text-muted)",
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background 150ms ease, border-color 150ms ease, color 150ms ease",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🎢 Coasters only
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Ride grid ────────────────────────────────────────────────────── */}
|
||||||
|
<div style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
||||||
|
gap: 6,
|
||||||
|
}}>
|
||||||
|
{openRides.map((ride) => <RideRow key={ride.name} ride={ride} />)}
|
||||||
|
{closedRides.map((ride) => <RideRow key={ride.name} ride={ride} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RideRow({ ride }: { ride: LiveRide }) {
|
||||||
|
const showWait = ride.isOpen && ride.waitMinutes > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 10,
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
border: `1px solid ${ride.isOpen ? "var(--color-open-border)" : "var(--color-border)"}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
opacity: ride.isOpen ? 1 : 0.6,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||||
|
<span style={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: ride.isOpen ? "var(--color-open-text)" : "var(--color-text-dim)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}} />
|
||||||
|
<span title={ride.name} style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: ride.isOpen ? "var(--color-text)" : "var(--color-text-muted)",
|
||||||
|
fontWeight: ride.isOpen ? 500 : 400,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}>
|
||||||
|
{ride.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showWait && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
color: "var(--color-open-hours)",
|
||||||
|
fontWeight: 600,
|
||||||
|
flexShrink: 0,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}>
|
||||||
|
{ride.waitMinutes} min
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{ride.isOpen && !showWait && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: "0.68rem",
|
||||||
|
color: "var(--color-open-text)",
|
||||||
|
fontWeight: 500,
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: 0.7,
|
||||||
|
}}>
|
||||||
|
walk-on
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,36 +8,33 @@ interface MobileCardListProps {
|
|||||||
weekDates: string[];
|
weekDates: string[];
|
||||||
data: Record<string, Record<string, DayData>>;
|
data: Record<string, Record<string, DayData>>;
|
||||||
today: string;
|
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 (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 24, paddingTop: 16 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 20, paddingTop: 14 }}>
|
||||||
{Array.from(grouped.entries()).map(([region, parks]) => (
|
{Array.from(grouped.entries()).map(([region, parks]) => (
|
||||||
<div key={region} data-region={region}>
|
<div key={region} data-region={region}>
|
||||||
{/* Region heading */}
|
{/* Region heading */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 10,
|
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
paddingLeft: 2,
|
paddingLeft: 2,
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
|
||||||
width: 3,
|
|
||||||
height: 14,
|
|
||||||
borderRadius: 2,
|
|
||||||
background: "var(--color-region-accent)",
|
|
||||||
flexShrink: 0,
|
|
||||||
}} />
|
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: "0.65rem",
|
fontSize: "0.6rem",
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
letterSpacing: "0.1em",
|
letterSpacing: "0.14em",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
color: "var(--color-text-muted)",
|
color: "var(--color-text-secondary)",
|
||||||
}}>
|
}}>
|
||||||
{region}
|
— {region} —
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,6 +47,11 @@ export function MobileCardList({ grouped, weekDates, data, today }: MobileCardLi
|
|||||||
weekDates={weekDates}
|
weekDates={weekDates}
|
||||||
parkData={data[park.id] ?? {}}
|
parkData={data[park.id] ?? {}}
|
||||||
today={today}
|
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>
|
||||||
|
|||||||
@@ -1,170 +1,188 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Park } from "@/lib/scrapers/types";
|
import type { Park } from "@/lib/scrapers/types";
|
||||||
import type { DayData } from "@/lib/db";
|
import type { DayData } from "@/lib/db";
|
||||||
|
import { getTimezoneAbbr } from "@/lib/env";
|
||||||
|
|
||||||
interface ParkCardProps {
|
interface ParkCardProps {
|
||||||
park: Park;
|
park: Park;
|
||||||
weekDates: string[]; // 7 dates YYYY-MM-DD
|
weekDates: string[]; // 7 dates YYYY-MM-DD, Sun–Sat
|
||||||
parkData: Record<string, DayData>;
|
parkData: Record<string, DayData>;
|
||||||
today: string;
|
today: string;
|
||||||
|
openRideCount?: number;
|
||||||
|
coastersOnly?: boolean;
|
||||||
|
isOpen?: boolean;
|
||||||
|
isClosing?: boolean;
|
||||||
|
isWeatherDelay?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DOW_SHORT = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
const DOW = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
|
|
||||||
function parseDate(iso: string) {
|
export function ParkCard({ park, weekDates, parkData, today, openRideCount, coastersOnly, isOpen, isClosing, isWeatherDelay }: ParkCardProps) {
|
||||||
const d = new Date(iso + "T00:00:00");
|
const openDays = weekDates.filter((d) => parkData[d]?.isOpen && parkData[d]?.hoursLabel);
|
||||||
return { day: d.getDate(), dow: d.getDay(), isWeekend: d.getDay() === 0 || d.getDay() === 6 };
|
const tzAbbr = getTimezoneAbbr(park.timezone);
|
||||||
}
|
const isOpenToday = openDays.includes(today);
|
||||||
|
|
||||||
export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Link
|
||||||
|
href={`/park/${park.id}`}
|
||||||
data-park={park.name.toLowerCase()}
|
data-park={park.name.toLowerCase()}
|
||||||
style={{
|
style={{ textDecoration: "none", display: "block" }}
|
||||||
|
>
|
||||||
|
<div className="park-card" style={{
|
||||||
background: "var(--color-surface)",
|
background: "var(--color-surface)",
|
||||||
border: "1px solid var(--color-border)",
|
border: "1px solid var(--color-border)",
|
||||||
|
borderLeft: isOpen
|
||||||
|
? `3px solid ${isWeatherDelay ? "var(--color-weather-border)" : isClosing ? "var(--color-closing-border)" : "var(--color-open-border)"}`
|
||||||
|
: "1px solid var(--color-border)",
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
padding: "14px 14px 12px",
|
overflow: "hidden",
|
||||||
|
}}>
|
||||||
|
{/* ── Card header ───────────────────────────────────────────────────── */}
|
||||||
|
<div style={{
|
||||||
|
padding: "14px 16px 12px",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
alignItems: "flex-start",
|
||||||
gap: 10,
|
justifyContent: "space-between",
|
||||||
}}
|
flexWrap: "wrap",
|
||||||
>
|
gap: 12,
|
||||||
{/* Park name + location */}
|
}}>
|
||||||
<div>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Link href={`/park/${park.id}`} className="park-name-link">
|
<div style={{
|
||||||
<span style={{ fontWeight: 600, fontSize: "0.9rem", lineHeight: 1.2 }}>
|
fontSize: "0.95rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text)",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}>
|
||||||
{park.name}
|
{park.name}
|
||||||
</span>
|
</div>
|
||||||
</Link>
|
<div style={{
|
||||||
<div style={{ fontSize: "0.7rem", color: "var(--color-text-muted)", marginTop: 2 }}>
|
fontSize: "0.72rem",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
marginTop: 3,
|
||||||
|
}}>
|
||||||
{park.location.city}, {park.location.state}
|
{park.location.city}, {park.location.state}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 7-day grid */}
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 5 }}>
|
||||||
|
{isOpenToday ? (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: "grid",
|
background: isWeatherDelay ? "var(--color-weather-bg)" : isClosing ? "var(--color-closing-bg)" : "var(--color-open-bg)",
|
||||||
gridTemplateColumns: "repeat(7, 1fr)",
|
border: `1px solid ${isWeatherDelay ? "var(--color-weather-border)" : isClosing ? "var(--color-closing-border)" : "var(--color-open-border)"}`,
|
||||||
gap: 4,
|
borderRadius: 20,
|
||||||
|
padding: "4px 10px",
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: isWeatherDelay ? "var(--color-weather-text)" : isClosing ? "var(--color-closing-text)" : "var(--color-open-text)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
letterSpacing: "0.03em",
|
||||||
}}>
|
}}>
|
||||||
{weekDates.map((date) => {
|
{isWeatherDelay ? "⛈ Weather Delay" : isClosing ? "Closing" : "Open today"}
|
||||||
const pd = parseDate(date);
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: "4px 10px",
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}>
|
||||||
|
Closed today
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isOpenToday && isWeatherDelay && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
color: "var(--color-weather-hours, #bfdbfe)",
|
||||||
|
fontWeight: 500,
|
||||||
|
textAlign: "right",
|
||||||
|
}}>
|
||||||
|
⛈ Weather Delay
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isOpenToday && !isWeatherDelay && openRideCount !== undefined && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
color: isClosing ? "var(--color-closing-hours)" : "var(--color-open-hours)",
|
||||||
|
fontWeight: 500,
|
||||||
|
textAlign: "right",
|
||||||
|
}}>
|
||||||
|
{openRideCount} {coastersOnly
|
||||||
|
? (openRideCount === 1 ? "coaster" : "coasters")
|
||||||
|
: (openRideCount === 1 ? "ride" : "rides")} operating
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Open days list ────────────────────────────────────────────────── */}
|
||||||
|
{openDays.length > 0 && (
|
||||||
|
<div style={{ borderTop: "1px solid var(--color-border-subtle)" }}>
|
||||||
|
{openDays.map((date, i) => {
|
||||||
|
const dow = new Date(date + "T00:00:00").getDay();
|
||||||
const isToday = date === today;
|
const isToday = date === today;
|
||||||
const dayData = parkData[date];
|
const dayData = parkData[date];
|
||||||
const isOpen = dayData?.isOpen && dayData?.hoursLabel;
|
const isPH = dayData.specialType === "passholder_preview";
|
||||||
const isPH = dayData?.specialType === "passholder_preview";
|
const isLast = i === openDays.length - 1;
|
||||||
|
|
||||||
let cellBg = "transparent";
|
|
||||||
let cellBorder = "1px solid var(--color-border-subtle)";
|
|
||||||
const cellBorderRadius = "6px";
|
|
||||||
|
|
||||||
if (isToday) {
|
|
||||||
cellBg = "var(--color-today-bg)";
|
|
||||||
cellBorder = `1px solid var(--color-today-border)`;
|
|
||||||
} else if (pd.isWeekend) {
|
|
||||||
cellBg = "var(--color-weekend-header)";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={date} style={{
|
<div
|
||||||
|
key={date}
|
||||||
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 3,
|
justifyContent: "space-between",
|
||||||
padding: "6px 2px",
|
padding: "9px 16px",
|
||||||
background: cellBg,
|
background: isToday ? "var(--color-today-bg)" : "transparent",
|
||||||
border: cellBorder,
|
borderBottom: isLast ? "none" : "1px solid var(--color-border-subtle)",
|
||||||
borderRadius: cellBorderRadius,
|
}}
|
||||||
minWidth: 0,
|
>
|
||||||
}}>
|
|
||||||
{/* Day name */}
|
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: "0.6rem",
|
fontSize: "0.82rem",
|
||||||
textTransform: "uppercase",
|
fontWeight: isToday ? 600 : 400,
|
||||||
letterSpacing: "0.04em",
|
color: isToday
|
||||||
color: isToday ? "var(--color-today-text)" : pd.isWeekend ? "var(--color-text-secondary)" : "var(--color-text-muted)",
|
? "var(--color-today-text)"
|
||||||
fontWeight: isToday || pd.isWeekend ? 600 : 400,
|
: "var(--color-text-secondary)",
|
||||||
}}>
|
}}>
|
||||||
{DOW_SHORT[pd.dow]}
|
{isToday ? "Today" : DOW[dow]}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Date number */}
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
{isPH && (
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: "0.8rem",
|
fontSize: "0.65rem",
|
||||||
fontWeight: isToday ? 700 : 500,
|
|
||||||
color: isToday ? "var(--color-today-text)" : pd.isWeekend ? "var(--color-text)" : "var(--color-text-secondary)",
|
|
||||||
lineHeight: 1,
|
|
||||||
}}>
|
|
||||||
{pd.day}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
{!dayData ? (
|
|
||||||
<span style={{ fontSize: "0.65rem", color: "var(--color-text-dim)", lineHeight: 1 }}>—</span>
|
|
||||||
) : isPH && isOpen ? (
|
|
||||||
<span style={{
|
|
||||||
fontSize: "0.6rem",
|
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "var(--color-ph-label)",
|
color: "var(--color-ph-label)",
|
||||||
letterSpacing: "0.02em",
|
letterSpacing: "0.04em",
|
||||||
textAlign: "center",
|
textTransform: "uppercase",
|
||||||
lineHeight: 1.2,
|
|
||||||
}}>
|
}}>
|
||||||
PH
|
Passholder
|
||||||
</span>
|
</span>
|
||||||
) : isOpen ? (
|
)}
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: "0.58rem",
|
fontSize: "0.82rem",
|
||||||
fontWeight: 600,
|
fontWeight: isToday ? 600 : 500,
|
||||||
color: "var(--color-open-text)",
|
color: isPH
|
||||||
lineHeight: 1,
|
? "var(--color-ph-hours)"
|
||||||
textAlign: "center",
|
: isToday
|
||||||
|
? "var(--color-today-text)"
|
||||||
|
: "var(--color-open-hours)",
|
||||||
}}>
|
}}>
|
||||||
Open
|
{dayData.hoursLabel}{" "}
|
||||||
|
<span style={{ fontSize: "0.68rem", fontWeight: 400, color: "var(--color-text-dim)" }}>
|
||||||
|
{tzAbbr}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
<span style={{ fontSize: "0.9rem", color: "var(--color-text-dim)", lineHeight: 1 }}>·</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hours detail row — show the open day hours inline */}
|
|
||||||
{weekDates.some((d) => parkData[d]?.isOpen && parkData[d]?.hoursLabel) && (
|
|
||||||
<div style={{
|
|
||||||
display: "flex",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: "4px 12px",
|
|
||||||
paddingTop: 4,
|
|
||||||
borderTop: "1px solid var(--color-border-subtle)",
|
|
||||||
}}>
|
|
||||||
{weekDates.map((date) => {
|
|
||||||
const pd = parseDate(date);
|
|
||||||
const dayData = parkData[date];
|
|
||||||
if (!dayData?.isOpen || !dayData?.hoursLabel) return null;
|
|
||||||
const isPH = dayData.specialType === "passholder_preview";
|
|
||||||
return (
|
|
||||||
<span key={date} style={{
|
|
||||||
fontSize: "0.68rem",
|
|
||||||
color: isPH ? "var(--color-ph-hours)" : "var(--color-open-hours)",
|
|
||||||
display: "flex",
|
|
||||||
gap: 4,
|
|
||||||
alignItems: "center",
|
|
||||||
}}>
|
|
||||||
<span style={{ color: "var(--color-text-muted)", fontWeight: 600 }}>
|
|
||||||
{DOW_SHORT[pd.dow]}
|
|
||||||
</span>
|
|
||||||
{dayData.hoursLabel}
|
|
||||||
{isPH && (
|
|
||||||
<span style={{ color: "var(--color-ph-label)", fontSize: "0.6rem", fontWeight: 700 }}>PH</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { DayData } from "@/lib/db";
|
import type { DayData } from "@/lib/db";
|
||||||
|
import { getTimezoneAbbr } from "@/lib/env";
|
||||||
|
|
||||||
interface ParkMonthCalendarProps {
|
interface ParkMonthCalendarProps {
|
||||||
parkId: string;
|
parkId: string;
|
||||||
@@ -7,6 +8,7 @@ interface ParkMonthCalendarProps {
|
|||||||
month: number; // 1-indexed
|
month: number; // 1-indexed
|
||||||
monthData: Record<string, DayData>; // 'YYYY-MM-DD' → DayData
|
monthData: Record<string, DayData>; // 'YYYY-MM-DD' → DayData
|
||||||
today: string; // YYYY-MM-DD
|
today: string; // YYYY-MM-DD
|
||||||
|
timezone: string; // IANA timezone, e.g. "America/New_York"
|
||||||
}
|
}
|
||||||
|
|
||||||
const DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
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();
|
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 firstDow = new Date(year, month - 1, 1).getDay(); // 0=Sun
|
||||||
const totalDays = daysInMonth(year, month);
|
const totalDays = daysInMonth(year, month);
|
||||||
|
|
||||||
@@ -115,20 +118,29 @@ export function ParkMonthCalendar({ parkId, year, month, monthData, today }: Par
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Weeks */}
|
{/*
|
||||||
{weeks.map((week, wi) => (
|
All day cells in ONE flat grid — eliminates per-week wrapper
|
||||||
<div key={wi} style={{
|
divs that caused independent row heights and the slant effect.
|
||||||
display: "grid",
|
Row height is controlled responsively via .park-calendar-grid CSS:
|
||||||
gridTemplateColumns: "repeat(7, 1fr)",
|
mobile = 72px fixed, sm+ = minmax(96px, auto).
|
||||||
borderBottom: wi < weeks.length - 1 ? "1px solid var(--color-border-subtle)" : "none",
|
*/}
|
||||||
}}>
|
<div
|
||||||
{week.map((cell, ci) => {
|
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) {
|
if (!cell.day || !cell.iso) {
|
||||||
return (
|
return (
|
||||||
<div key={ci} style={{
|
<div key={idx} style={{
|
||||||
minHeight: 72,
|
overflow: "hidden",
|
||||||
background: ci === 0 || ci === 6 ? "var(--color-weekend-header)" : "transparent",
|
background: ci === 0 || ci === 6 ? "var(--color-weekend-header)" : "transparent",
|
||||||
borderRight: ci < 6 ? "1px solid var(--color-border-subtle)" : "none",
|
borderRight,
|
||||||
|
borderBottom,
|
||||||
}} />
|
}} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -146,19 +158,20 @@ export function ParkMonthCalendar({ parkId, year, month, monthData, today }: Par
|
|||||||
: "transparent";
|
: "transparent";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={ci} style={{
|
<div key={idx} style={{
|
||||||
minHeight: 72,
|
padding: "8px 8px",
|
||||||
padding: "8px 10px",
|
overflow: "hidden",
|
||||||
background: bg,
|
background: bg,
|
||||||
borderRight: ci < 6 ? "1px solid var(--color-border-subtle)" : "none",
|
borderRight,
|
||||||
|
borderBottom,
|
||||||
borderLeft: isToday ? "2px solid var(--color-today-border)" : "none",
|
borderLeft: isToday ? "2px solid var(--color-today-border)" : "none",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 4,
|
gap: 6,
|
||||||
}}>
|
}}>
|
||||||
{/* Date number */}
|
{/* Date number */}
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: "0.85rem",
|
fontSize: "0.88rem",
|
||||||
fontWeight: isToday ? 700 : isWeekend ? 600 : 400,
|
fontWeight: isToday ? 700 : isWeekend ? 600 : 400,
|
||||||
color: isToday
|
color: isToday
|
||||||
? "var(--color-today-text)"
|
? "var(--color-today-text)"
|
||||||
@@ -166,46 +179,65 @@ export function ParkMonthCalendar({ parkId, year, month, monthData, today }: Par
|
|||||||
? "var(--color-text)"
|
? "var(--color-text)"
|
||||||
: "var(--color-text-muted)",
|
: "var(--color-text-muted)",
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
|
flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
{cell.day}
|
{cell.day}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Status */}
|
{/* ── Mobile status: colored dot only (sm:hidden) ──────── */}
|
||||||
|
{/* Cells are ~55px wide on mobile — no room for hours text */}
|
||||||
{!dayData ? (
|
{!dayData ? (
|
||||||
<span style={{ fontSize: "0.65rem", color: "var(--color-text-dim)" }}>—</span>
|
<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 ? (
|
) : isPH && isOpen ? (
|
||||||
<div style={{
|
<div className="hidden sm:block" style={{
|
||||||
background: "var(--color-ph-bg)",
|
background: "var(--color-ph-bg)",
|
||||||
border: "1px solid var(--color-ph-border)",
|
border: "1px solid var(--color-ph-border)",
|
||||||
borderRadius: 4,
|
borderRadius: 5,
|
||||||
padding: "2px 5px",
|
padding: "8px 6px",
|
||||||
|
textAlign: "center",
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: "0.55rem", fontWeight: 700, color: "var(--color-ph-label)", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
<div style={{ fontSize: "0.6rem", fontWeight: 700, color: "var(--color-ph-label)", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||||
Passholder
|
Passholder
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: "0.6rem", color: "var(--color-ph-hours)", marginTop: 1 }}>
|
<div style={{ fontSize: "0.65rem", color: "var(--color-ph-hours)", marginTop: 4 }}>
|
||||||
{dayData.hoursLabel}
|
{dayData.hoursLabel}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ fontSize: "0.58rem", color: "var(--color-ph-label)", opacity: 0.75, marginTop: 3, letterSpacing: "0.04em" }}>
|
||||||
|
{tzAbbr}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : isOpen ? (
|
) : isOpen ? (
|
||||||
<div style={{
|
<div className="hidden sm:block" style={{
|
||||||
background: "var(--color-open-bg)",
|
background: "var(--color-open-bg)",
|
||||||
border: "1px solid var(--color-open-border)",
|
border: "1px solid var(--color-open-border)",
|
||||||
borderRadius: 4,
|
borderRadius: 5,
|
||||||
padding: "2px 5px",
|
padding: "8px 6px",
|
||||||
|
textAlign: "center",
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: "0.6rem", color: "var(--color-open-hours)" }}>
|
<div style={{ fontSize: "0.65rem", color: "var(--color-open-hours)" }}>
|
||||||
{dayData.hoursLabel}
|
{dayData.hoursLabel}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ fontSize: "0.58rem", color: "var(--color-open-hours)", opacity: 0.6, marginTop: 4, letterSpacing: "0.04em" }}>
|
||||||
|
{tzAbbr}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ fontSize: "0.9rem", color: "var(--color-text-dim)", lineHeight: 1 }}>·</span>
|
<span className="hidden sm:inline" style={{ fontSize: "1rem", color: "var(--color-text-dim)", lineHeight: 1 }}>·</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -215,12 +247,13 @@ const navLinkStyle: React.CSSProperties = {
|
|||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
padding: "6px 14px",
|
padding: "10px 16px",
|
||||||
borderRadius: 6,
|
borderRadius: 8,
|
||||||
border: "1px solid var(--color-border)",
|
border: "1px solid var(--color-border)",
|
||||||
background: "var(--color-surface)",
|
background: "var(--color-surface)",
|
||||||
color: "var(--color-text-muted)",
|
color: "var(--color-text-muted)",
|
||||||
fontSize: "1rem",
|
fontSize: "1rem",
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
|
minWidth: 44,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,12 +3,18 @@ import Link from "next/link";
|
|||||||
import type { Park } from "@/lib/scrapers/types";
|
import type { Park } from "@/lib/scrapers/types";
|
||||||
import type { DayData } from "@/lib/db";
|
import type { DayData } from "@/lib/db";
|
||||||
import type { Region } from "@/lib/parks";
|
import type { Region } from "@/lib/parks";
|
||||||
|
import { getTodayLocal, getTimezoneAbbr } from "@/lib/env";
|
||||||
|
|
||||||
interface WeekCalendarProps {
|
interface WeekCalendarProps {
|
||||||
parks: Park[];
|
parks: Park[];
|
||||||
weekDates: string[]; // 7 dates, YYYY-MM-DD, Sun–Sat
|
weekDates: string[]; // 7 dates, YYYY-MM-DD, Sun–Sat
|
||||||
data: Record<string, Record<string, DayData>>; // parkId → date → DayData
|
data: Record<string, Record<string, DayData>>; // parkId → date → DayData
|
||||||
grouped?: Map<Region, Park[]>; // pre-grouped parks (if provided, renders region headers)
|
grouped?: Map<Region, Park[]>; // pre-grouped parks (if provided, renders region headers)
|
||||||
|
rideCounts?: Record<string, number>; // parkId → open ride/coaster count for today
|
||||||
|
coastersOnly?: boolean;
|
||||||
|
openParkIds?: string[];
|
||||||
|
closingParkIds?: string[];
|
||||||
|
weatherDelayParkIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
@@ -29,12 +35,12 @@ function parseDate(iso: string) {
|
|||||||
|
|
||||||
function DayCell({
|
function DayCell({
|
||||||
dayData,
|
dayData,
|
||||||
isToday,
|
|
||||||
isWeekend,
|
isWeekend,
|
||||||
|
tzAbbr,
|
||||||
}: {
|
}: {
|
||||||
dayData: DayData | undefined;
|
dayData: DayData | undefined;
|
||||||
isToday: boolean;
|
|
||||||
isWeekend: boolean;
|
isWeekend: boolean;
|
||||||
|
tzAbbr: string;
|
||||||
}) {
|
}) {
|
||||||
const base: React.CSSProperties = {
|
const base: React.CSSProperties = {
|
||||||
padding: 0,
|
padding: 0,
|
||||||
@@ -42,15 +48,8 @@ function DayCell({
|
|||||||
verticalAlign: "middle",
|
verticalAlign: "middle",
|
||||||
borderBottom: "1px solid var(--color-border)",
|
borderBottom: "1px solid var(--color-border)",
|
||||||
borderLeft: "1px solid var(--color-border)",
|
borderLeft: "1px solid var(--color-border)",
|
||||||
height: 56,
|
height: 72,
|
||||||
background: isToday
|
background: isWeekend ? "var(--color-weekend-header)" : "transparent",
|
||||||
? "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,
|
|
||||||
transition: "background 120ms ease",
|
transition: "background 120ms ease",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,7 +71,7 @@ function DayCell({
|
|||||||
|
|
||||||
if (dayData.specialType === "passholder_preview") {
|
if (dayData.specialType === "passholder_preview") {
|
||||||
return (
|
return (
|
||||||
<td style={{ ...base, padding: 4 }}>
|
<td style={{ ...base, padding: 6 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: "var(--color-ph-bg)",
|
background: "var(--color-ph-bg)",
|
||||||
border: "1px solid var(--color-ph-border)",
|
border: "1px solid var(--color-ph-border)",
|
||||||
@@ -99,13 +98,21 @@ function DayCell({
|
|||||||
</span>
|
</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
color: "var(--color-ph-hours)",
|
color: "var(--color-ph-hours)",
|
||||||
fontSize: "0.7rem",
|
fontSize: "0.78rem",
|
||||||
fontWeight: 500,
|
fontWeight: 600,
|
||||||
letterSpacing: "-0.01em",
|
letterSpacing: "-0.01em",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}>
|
}}>
|
||||||
{dayData.hoursLabel}
|
{dayData.hoursLabel}
|
||||||
</span>
|
</span>
|
||||||
|
<span style={{
|
||||||
|
color: "var(--color-ph-label)",
|
||||||
|
fontSize: "0.6rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
}}>
|
||||||
|
{tzAbbr}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -117,23 +124,34 @@ function DayCell({
|
|||||||
background: "var(--color-open-bg)",
|
background: "var(--color-open-bg)",
|
||||||
border: "1px solid var(--color-open-border)",
|
border: "1px solid var(--color-open-border)",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
padding: "6px 4px",
|
padding: "4px",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
gap: 2,
|
||||||
transition: "filter 150ms ease",
|
transition: "filter 150ms ease",
|
||||||
}}>
|
}}>
|
||||||
<span style={{
|
<span style={{
|
||||||
color: "var(--color-open-hours)",
|
color: "var(--color-open-hours)",
|
||||||
fontSize: "0.7rem",
|
fontSize: "0.78rem",
|
||||||
fontWeight: 500,
|
fontWeight: 600,
|
||||||
letterSpacing: "-0.01em",
|
letterSpacing: "-0.01em",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}>
|
}}>
|
||||||
{dayData.hoursLabel}
|
{dayData.hoursLabel}
|
||||||
</span>
|
</span>
|
||||||
|
<span style={{
|
||||||
|
color: "var(--color-open-hours)",
|
||||||
|
fontSize: "0.6rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
opacity: 0.6,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
}}>
|
||||||
|
{tzAbbr}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -148,17 +166,16 @@ function RegionHeader({ region, colSpan }: { region: string; colSpan: number })
|
|||||||
padding: "10px 14px 6px",
|
padding: "10px 14px 6px",
|
||||||
background: "var(--color-region-bg)",
|
background: "var(--color-region-bg)",
|
||||||
borderBottom: "1px solid var(--color-border-subtle)",
|
borderBottom: "1px solid var(--color-border-subtle)",
|
||||||
borderLeft: "3px solid var(--color-region-accent)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: "0.65rem",
|
fontSize: "0.6rem",
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
letterSpacing: "0.1em",
|
letterSpacing: "0.14em",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
color: "var(--color-text-muted)",
|
color: "var(--color-text-secondary)",
|
||||||
}}>
|
}}>
|
||||||
{region}
|
— {region} —
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -171,16 +188,28 @@ function ParkRow({
|
|||||||
weekDates,
|
weekDates,
|
||||||
parsedDates,
|
parsedDates,
|
||||||
parkData,
|
parkData,
|
||||||
today,
|
rideCounts,
|
||||||
|
coastersOnly,
|
||||||
|
openParkIds,
|
||||||
|
closingParkIds,
|
||||||
|
weatherDelayParkIds,
|
||||||
}: {
|
}: {
|
||||||
park: Park;
|
park: Park;
|
||||||
parkIdx: number;
|
parkIdx: number;
|
||||||
weekDates: string[];
|
weekDates: string[];
|
||||||
parsedDates: ReturnType<typeof parseDate>[];
|
parsedDates: ReturnType<typeof parseDate>[];
|
||||||
parkData: Record<string, DayData>;
|
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 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 (
|
return (
|
||||||
<tr
|
<tr
|
||||||
className="park-row"
|
className="park-row"
|
||||||
@@ -191,38 +220,62 @@ function ParkRow({
|
|||||||
position: "sticky",
|
position: "sticky",
|
||||||
left: 0,
|
left: 0,
|
||||||
zIndex: 5,
|
zIndex: 5,
|
||||||
padding: "10px 14px",
|
padding: 0,
|
||||||
borderBottom: "1px solid var(--color-border)",
|
borderBottom: "1px solid var(--color-border)",
|
||||||
borderRight: "1px solid var(--color-border)",
|
borderRight: "1px solid var(--color-border)",
|
||||||
whiteSpace: "nowrap",
|
borderLeft: isOpen
|
||||||
|
? `3px solid ${isWeatherDelay ? "var(--color-weather-border)" : isClosing ? "var(--color-closing-border)" : "var(--color-open-border)"}`
|
||||||
|
: "3px solid transparent",
|
||||||
verticalAlign: "middle",
|
verticalAlign: "middle",
|
||||||
background: rowBg,
|
background: rowBg,
|
||||||
transition: "background 120ms ease",
|
transition: "background 120ms ease",
|
||||||
}}>
|
}}>
|
||||||
<Link href={`/park/${park.id}`} className="park-name-link">
|
<Link href={`/park/${park.id}`} className="park-name-link" style={{
|
||||||
<span style={{ fontWeight: 500, fontSize: "0.85rem", lineHeight: 1.2 }}>
|
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}
|
{park.name}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</div>
|
||||||
<div style={{ fontSize: "0.7rem", color: "var(--color-text-muted)", marginTop: 2 }}>
|
<div style={{ fontSize: "0.7rem", color: "var(--color-text-muted)", marginTop: 2 }}>
|
||||||
{park.location.city}, {park.location.state}
|
{park.location.city}, {park.location.state}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{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>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{weekDates.map((date, i) => (
|
{weekDates.map((date, i) => (
|
||||||
<DayCell
|
<DayCell
|
||||||
key={date}
|
key={date}
|
||||||
dayData={parkData[date]}
|
dayData={parkData[date]}
|
||||||
isToday={date === today}
|
|
||||||
isWeekend={parsedDates[i].isWeekend}
|
isWeekend={parsedDates[i].isWeekend}
|
||||||
|
tzAbbr={tzAbbr}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WeekCalendar({ parks, weekDates, data, grouped }: WeekCalendarProps) {
|
export function WeekCalendar({ parks, weekDates, data, grouped, rideCounts, coastersOnly, openParkIds, closingParkIds, weatherDelayParkIds }: WeekCalendarProps) {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = getTodayLocal();
|
||||||
const parsedDates = weekDates.map(parseDate);
|
const parsedDates = weekDates.map(parseDate);
|
||||||
|
|
||||||
const firstMonth = parsedDates[0].month;
|
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
|
const colSpan = weekDates.length + 1; // park col + 7 day cols
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ overflowX: "auto", overflowY: "visible" }}>
|
<div style={{ overflowX: "auto", overflowY: "visible", paddingRight: 16 }}>
|
||||||
<table style={{
|
<table style={{
|
||||||
borderCollapse: "collapse",
|
borderCollapse: "collapse",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -335,7 +388,11 @@ export function WeekCalendar({ parks, weekDates, data, grouped }: WeekCalendarPr
|
|||||||
weekDates={weekDates}
|
weekDates={weekDates}
|
||||||
parsedDates={parsedDates}
|
parsedDates={parsedDates}
|
||||||
parkData={data[park.id] ?? {}}
|
parkData={data[park.id] ?? {}}
|
||||||
today={today}
|
rideCounts={rideCounts}
|
||||||
|
coastersOnly={coastersOnly}
|
||||||
|
openParkIds={openParkIds}
|
||||||
|
closingParkIds={closingParkIds}
|
||||||
|
weatherDelayParkIds={weatherDelayParkIds}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
interface WeekNavProps {
|
interface WeekNavProps {
|
||||||
@@ -32,8 +33,19 @@ function shiftWeek(weekStart: string, delta: number): string {
|
|||||||
|
|
||||||
export function WeekNav({ weekStart, weekDates, isCurrentWeek }: WeekNavProps) {
|
export function WeekNav({ weekStart, weekDates, isCurrentWeek }: WeekNavProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const nav = (delta: number) =>
|
const nav = (delta: number) => {
|
||||||
router.push(`/?week=${shiftWeek(weekStart, delta)}`);
|
router.push(`/?week=${shiftWeek(weekStart, delta)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||||
|
if (e.key === "ArrowLeft") nav(-1);
|
||||||
|
if (e.key === "ArrowRight") nav(1);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [weekStart]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
@@ -63,7 +75,7 @@ export function WeekNav({ weekStart, weekDates, isCurrentWeek }: WeekNavProps) {
|
|||||||
fontSize: "1rem",
|
fontSize: "1rem",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "var(--color-text)",
|
color: "var(--color-text)",
|
||||||
minWidth: 200,
|
minWidth: 140,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
letterSpacing: "-0.01em",
|
letterSpacing: "-0.01em",
|
||||||
fontVariantNumeric: "tabular-nums",
|
fontVariantNumeric: "tabular-nums",
|
||||||
@@ -85,8 +97,8 @@ export function WeekNav({ weekStart, weekDates, isCurrentWeek }: WeekNavProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const navBtnStyle: React.CSSProperties = {
|
const navBtnStyle: React.CSSProperties = {
|
||||||
padding: "6px 14px",
|
padding: "10px 16px",
|
||||||
borderRadius: 6,
|
borderRadius: 8,
|
||||||
border: "1px solid var(--color-border)",
|
border: "1px solid var(--color-border)",
|
||||||
background: "var(--color-surface)",
|
background: "var(--color-surface)",
|
||||||
color: "var(--color-text-muted)",
|
color: "var(--color-text-muted)",
|
||||||
@@ -94,11 +106,13 @@ const navBtnStyle: React.CSSProperties = {
|
|||||||
fontSize: "1rem",
|
fontSize: "1rem",
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
transition: "background 150ms ease, border-color 150ms ease, color 150ms ease",
|
transition: "background 150ms ease, border-color 150ms ease, color 150ms ease",
|
||||||
|
minWidth: 44,
|
||||||
|
textAlign: "center",
|
||||||
};
|
};
|
||||||
|
|
||||||
const navBtnHover: React.CSSProperties = {
|
const navBtnHover: React.CSSProperties = {
|
||||||
padding: "6px 14px",
|
padding: "10px 16px",
|
||||||
borderRadius: 6,
|
borderRadius: 8,
|
||||||
border: "1px solid var(--color-text-dim)",
|
border: "1px solid var(--color-text-dim)",
|
||||||
background: "var(--color-surface-2)",
|
background: "var(--color-surface-2)",
|
||||||
color: "var(--color-text-secondary)",
|
color: "var(--color-text-secondary)",
|
||||||
@@ -106,6 +120,8 @@ const navBtnHover: React.CSSProperties = {
|
|||||||
fontSize: "1rem",
|
fontSize: "1rem",
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
transition: "background 150ms ease, border-color 150ms ease, color 150ms ease",
|
transition: "background 150ms ease, border-color 150ms ease, color 150ms ease",
|
||||||
|
minWidth: 44,
|
||||||
|
textAlign: "center",
|
||||||
};
|
};
|
||||||
|
|
||||||
const todayBtnStyle: React.CSSProperties = {
|
const todayBtnStyle: React.CSSProperties = {
|
||||||
|
|||||||
416
data/park-meta.json
Normal file
416
data/park-meta.json
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
{
|
||||||
|
"greatadventure": {
|
||||||
|
"rcdb_id": 4534,
|
||||||
|
"coasters": [
|
||||||
|
"Superman - Ultimate Flight",
|
||||||
|
"El Toro",
|
||||||
|
"Dark Knight",
|
||||||
|
"Joker",
|
||||||
|
"Jersey Devil Coaster",
|
||||||
|
"Lil' Devil Coaster",
|
||||||
|
"Flash: Vertical Velocity",
|
||||||
|
"Batman The Ride",
|
||||||
|
"Skull Mountain",
|
||||||
|
"Runaway Mine Train",
|
||||||
|
"Medusa",
|
||||||
|
"Harley Quinn Crazy Train",
|
||||||
|
"Nitro"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:40:09.731Z"
|
||||||
|
},
|
||||||
|
"magicmountain": {
|
||||||
|
"rcdb_id": 4532,
|
||||||
|
"coasters": [
|
||||||
|
"Ninja",
|
||||||
|
"New Revolution",
|
||||||
|
"Batman The Ride",
|
||||||
|
"Viper",
|
||||||
|
"Gold Rusher",
|
||||||
|
"Riddler's Revenge",
|
||||||
|
"Canyon Blaster",
|
||||||
|
"Goliath",
|
||||||
|
"X2",
|
||||||
|
"Scream!",
|
||||||
|
"Tatsu",
|
||||||
|
"Apocalypse the Ride",
|
||||||
|
"Road Runner Express",
|
||||||
|
"Speedy Gonzales Hot Rod Racers",
|
||||||
|
"Full Throttle",
|
||||||
|
"Twisted Colossus",
|
||||||
|
"West Coast Racers",
|
||||||
|
"Wonder Woman Flight of Courage"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:45:43.666Z"
|
||||||
|
},
|
||||||
|
"greatamerica": {
|
||||||
|
"rcdb_id": 4530,
|
||||||
|
"coasters": [
|
||||||
|
"Demon",
|
||||||
|
"Batman The Ride",
|
||||||
|
"American Eagle",
|
||||||
|
"Viper",
|
||||||
|
"Whizzer",
|
||||||
|
"Sprocket Rockets",
|
||||||
|
"Raging Bull",
|
||||||
|
"Flash: Vertical Velocity",
|
||||||
|
"Superman - Ultimate Flight",
|
||||||
|
"Dark Knight",
|
||||||
|
"Little Dipper",
|
||||||
|
"Goliath",
|
||||||
|
"X-Flight",
|
||||||
|
"Joker",
|
||||||
|
"Maxx Force",
|
||||||
|
"Wrath of Rakshasa"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:29:24.092Z"
|
||||||
|
},
|
||||||
|
"overgeorgia": {
|
||||||
|
"rcdb_id": 4535,
|
||||||
|
"coasters": [
|
||||||
|
"Blue Hawk",
|
||||||
|
"Great American Scream Machine",
|
||||||
|
"Dahlonega Mine Train",
|
||||||
|
"Batman The Ride",
|
||||||
|
"Georgia Scorcher",
|
||||||
|
"Superman - Ultimate Flight",
|
||||||
|
"Joker Funhouse Coaster",
|
||||||
|
"Goliath",
|
||||||
|
"Dare Devil Dive",
|
||||||
|
"Twisted Cyclone",
|
||||||
|
"Riddler Mindbender",
|
||||||
|
"Georgia Gold Rusher"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:29:26.121Z"
|
||||||
|
},
|
||||||
|
"overtexas": {
|
||||||
|
"rcdb_id": 4531,
|
||||||
|
"coasters": [
|
||||||
|
"Pandemonium",
|
||||||
|
"New Texas Giant",
|
||||||
|
"Joker",
|
||||||
|
"Aquaman: Power Wave",
|
||||||
|
"Shock Wave",
|
||||||
|
"Judge Roy Scream",
|
||||||
|
"Runaway Mine Train",
|
||||||
|
"Runaway Mountain",
|
||||||
|
"Mini Mine Train",
|
||||||
|
"Mr. Freeze",
|
||||||
|
"Batman The Ride",
|
||||||
|
"Titan",
|
||||||
|
"Wile E. Coyote's Grand Canyon Blaster"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:45:45.715Z"
|
||||||
|
},
|
||||||
|
"stlouis": {
|
||||||
|
"rcdb_id": 4536,
|
||||||
|
"coasters": [
|
||||||
|
"Ninja",
|
||||||
|
"River King Mine Train",
|
||||||
|
"Mr. Freeze Reverse Blast",
|
||||||
|
"Batman The Ride",
|
||||||
|
"Screamin' Eagle",
|
||||||
|
"Boss",
|
||||||
|
"Pandemonium",
|
||||||
|
"American Thunder",
|
||||||
|
"Boomerang",
|
||||||
|
"Rookie Racer"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:45:47.770Z"
|
||||||
|
},
|
||||||
|
"fiestatexas": {
|
||||||
|
"rcdb_id": 4538,
|
||||||
|
"coasters": [
|
||||||
|
"Batgirl Coaster Chase",
|
||||||
|
"Road Runner Express",
|
||||||
|
"Poltergeist",
|
||||||
|
"Boomerang Coast to Coaster",
|
||||||
|
"Superman Krypton Coaster",
|
||||||
|
"Pandemonium",
|
||||||
|
"Chupacabra",
|
||||||
|
"Iron Rattler",
|
||||||
|
"Batman The Ride",
|
||||||
|
"Wonder Woman Golden Lasso Coaster",
|
||||||
|
"Dr. Diabolical's Cliffhanger"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:45:49.819Z"
|
||||||
|
},
|
||||||
|
"newengland": {
|
||||||
|
"rcdb_id": 4565,
|
||||||
|
"coasters": [
|
||||||
|
"Joker",
|
||||||
|
"Thunderbolt",
|
||||||
|
"Great Chase",
|
||||||
|
"Riddler Revenge",
|
||||||
|
"Superman the Ride",
|
||||||
|
"Flashback",
|
||||||
|
"Catwoman's Whip",
|
||||||
|
"Pandemonium",
|
||||||
|
"Batman - The Dark Knight",
|
||||||
|
"Wicked Cyclone",
|
||||||
|
"Gotham City Gauntlet Escape from Arkham Asylum"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:45:51.866Z"
|
||||||
|
},
|
||||||
|
"discoverykingdom": {
|
||||||
|
"rcdb_id": 4711,
|
||||||
|
"coasters": [
|
||||||
|
"Roadrunner Express",
|
||||||
|
"Medusa",
|
||||||
|
"Cobra",
|
||||||
|
"Flash: Vertical Velocity",
|
||||||
|
"Kong",
|
||||||
|
"Boomerang",
|
||||||
|
"Superman Ultimate Flight",
|
||||||
|
"Joker",
|
||||||
|
"Batman The Ride",
|
||||||
|
"Sidewinder Safari"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:45:53.909Z"
|
||||||
|
},
|
||||||
|
"mexico": {
|
||||||
|
"rcdb_id": 4629,
|
||||||
|
"coasters": [
|
||||||
|
"Tsunami",
|
||||||
|
"Superman Krypton Coaster",
|
||||||
|
"Batgirl Batarang",
|
||||||
|
"Batman The Ride",
|
||||||
|
"Superman el Último Escape",
|
||||||
|
"Dark Knight",
|
||||||
|
"Joker",
|
||||||
|
"Medusa Steel Coaster",
|
||||||
|
"Wonder Woman",
|
||||||
|
"Speedway Stunt Coaster"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:45:55.963Z"
|
||||||
|
},
|
||||||
|
"greatescape": {
|
||||||
|
"rcdb_id": 4596,
|
||||||
|
"coasters": [
|
||||||
|
"Comet",
|
||||||
|
"Steamin' Demon",
|
||||||
|
"Flashback",
|
||||||
|
"Canyon Blaster",
|
||||||
|
"Frankie's Mine Train",
|
||||||
|
"Bobcat"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:45:58.013Z"
|
||||||
|
},
|
||||||
|
"darienlake": {
|
||||||
|
"rcdb_id": 4581,
|
||||||
|
"coasters": [
|
||||||
|
"Predator",
|
||||||
|
"Viper",
|
||||||
|
"Mind Eraser",
|
||||||
|
"Boomerang",
|
||||||
|
"Ride of Steel",
|
||||||
|
"Hoot N Holler",
|
||||||
|
"Moto Coaster",
|
||||||
|
"Tantrum"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:46:00.042Z"
|
||||||
|
},
|
||||||
|
"cedarpoint": {
|
||||||
|
"rcdb_id": 4529,
|
||||||
|
"coasters": [
|
||||||
|
"Raptor",
|
||||||
|
"Rougarou",
|
||||||
|
"Magnum XL-200",
|
||||||
|
"Blue Streak",
|
||||||
|
"Corkscrew",
|
||||||
|
"Gemini",
|
||||||
|
"Wilderness Run",
|
||||||
|
"Woodstock Express",
|
||||||
|
"Millennium Force",
|
||||||
|
"Iron Dragon",
|
||||||
|
"Cedar Creek Mine Ride",
|
||||||
|
"Maverick",
|
||||||
|
"GateKeeper",
|
||||||
|
"Valravn",
|
||||||
|
"Steel Vengeance",
|
||||||
|
"Top Thrill 2",
|
||||||
|
"Wild Mouse",
|
||||||
|
"Siren’s Curse"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:46:02.082Z"
|
||||||
|
},
|
||||||
|
"knotts": {
|
||||||
|
"rcdb_id": 4546,
|
||||||
|
"coasters": [
|
||||||
|
"Jaguar!",
|
||||||
|
"GhostRider",
|
||||||
|
"Xcelerator",
|
||||||
|
"Silver Bullet",
|
||||||
|
"Sierra Sidewinder",
|
||||||
|
"Pony Express",
|
||||||
|
"Coast Rider",
|
||||||
|
"HangTime",
|
||||||
|
"Snoopy’s Tenderpaw Twister Coaster"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:46:04.120Z"
|
||||||
|
},
|
||||||
|
"canadaswonderland": {
|
||||||
|
"rcdb_id": 4539,
|
||||||
|
"coasters": [
|
||||||
|
"Flight Deck",
|
||||||
|
"Dragon Fyre",
|
||||||
|
"Mighty Canadian Minebuster",
|
||||||
|
"Wilde Beast",
|
||||||
|
"Ghoster Coaster",
|
||||||
|
"Thunder Run",
|
||||||
|
"Bat",
|
||||||
|
"Vortex",
|
||||||
|
"Taxi Jam",
|
||||||
|
"Fly",
|
||||||
|
"Silver Streak",
|
||||||
|
"Backlot Stunt Coaster",
|
||||||
|
"Behemoth",
|
||||||
|
"Leviathan",
|
||||||
|
"Wonder Mountain's Guardian",
|
||||||
|
"Yukon Striker",
|
||||||
|
"Snoopy's Racing Railway",
|
||||||
|
"AlpenFury"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:46:06.152Z"
|
||||||
|
},
|
||||||
|
"carowinds": {
|
||||||
|
"rcdb_id": 4542,
|
||||||
|
"coasters": [
|
||||||
|
"Carolina Cyclone",
|
||||||
|
"Woodstock Express",
|
||||||
|
"Carolina Goldrusher",
|
||||||
|
"Hurler",
|
||||||
|
"Vortex",
|
||||||
|
"Wilderness Run",
|
||||||
|
"Afterburn",
|
||||||
|
"Flying Cobras",
|
||||||
|
"Thunder Striker",
|
||||||
|
"Fury 325",
|
||||||
|
"Copperhead Strike",
|
||||||
|
"Snoopy’s Racing Railway",
|
||||||
|
"Ricochet",
|
||||||
|
"Kiddy Hawk"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:46:08.185Z"
|
||||||
|
},
|
||||||
|
"kingsdominion": {
|
||||||
|
"rcdb_id": 4544,
|
||||||
|
"coasters": [
|
||||||
|
"Racer 75",
|
||||||
|
"Woodstock Express",
|
||||||
|
"Grizzly",
|
||||||
|
"Flight of Fear",
|
||||||
|
"Reptilian",
|
||||||
|
"Great Pumpkin Coaster",
|
||||||
|
"Apple Zapple",
|
||||||
|
"Backlot Stunt Coaster",
|
||||||
|
"Dominator",
|
||||||
|
"Pantherian",
|
||||||
|
"Twisted Timbers",
|
||||||
|
"Tumbili",
|
||||||
|
"Rapterra"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:46:10.223Z"
|
||||||
|
},
|
||||||
|
"kingsisland": {
|
||||||
|
"rcdb_id": 4540,
|
||||||
|
"coasters": [
|
||||||
|
"Flight of Fear",
|
||||||
|
"Beast",
|
||||||
|
"Racer",
|
||||||
|
"Adventure Express",
|
||||||
|
"Woodstock Express",
|
||||||
|
"Bat",
|
||||||
|
"Great Pumpkin Coaster",
|
||||||
|
"Invertigo",
|
||||||
|
"Diamondback",
|
||||||
|
"Banshee",
|
||||||
|
"Orion",
|
||||||
|
"Mystic Timbers",
|
||||||
|
"Snoopy's Soap Box Racers",
|
||||||
|
"Woodstock’s Air Rail",
|
||||||
|
"Queen City Stunt Coaster"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:46:12.251Z"
|
||||||
|
},
|
||||||
|
"valleyfair": {
|
||||||
|
"rcdb_id": 4552,
|
||||||
|
"coasters": [
|
||||||
|
"High Roller",
|
||||||
|
"Corkscrew",
|
||||||
|
"Excalibur",
|
||||||
|
"Wild Thing",
|
||||||
|
"Mad Mouse",
|
||||||
|
"Steel Venom",
|
||||||
|
"Renegade",
|
||||||
|
"Cosmic Coaster"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:46:14.298Z"
|
||||||
|
},
|
||||||
|
"worldsoffun": {
|
||||||
|
"rcdb_id": 4533,
|
||||||
|
"coasters": [
|
||||||
|
"Timber Wolf",
|
||||||
|
"Cosmic Coaster",
|
||||||
|
"Mamba",
|
||||||
|
"Spinning Dragons",
|
||||||
|
"Patriot",
|
||||||
|
"Prowler",
|
||||||
|
"Zambezi Zinger",
|
||||||
|
"Boomerang"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:46:16.328Z"
|
||||||
|
},
|
||||||
|
"miadventure": {
|
||||||
|
"rcdb_id": 4578,
|
||||||
|
"coasters": [
|
||||||
|
"Corkscrew",
|
||||||
|
"Wolverine Wildcat",
|
||||||
|
"Zach's Zoomer",
|
||||||
|
"Shivering Timbers",
|
||||||
|
"Mad Mouse",
|
||||||
|
"Thunderhawk",
|
||||||
|
"Woodstock Express"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:46:18.370Z"
|
||||||
|
},
|
||||||
|
"dorneypark": {
|
||||||
|
"rcdb_id": 4588,
|
||||||
|
"coasters": [
|
||||||
|
"Thunderhawk",
|
||||||
|
"Steel Force",
|
||||||
|
"Wild Mouse",
|
||||||
|
"Woodstock Express",
|
||||||
|
"Talon",
|
||||||
|
"Hydra the Revenge",
|
||||||
|
"Possessed",
|
||||||
|
"Iron Menace"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:46:20.413Z"
|
||||||
|
},
|
||||||
|
"cagreatamerica": {
|
||||||
|
"rcdb_id": 4541,
|
||||||
|
"coasters": [
|
||||||
|
"Demon",
|
||||||
|
"Grizzly",
|
||||||
|
"Woodstock Express",
|
||||||
|
"Patriot",
|
||||||
|
"Flight Deck",
|
||||||
|
"Lucy's Crabbie Cabbies",
|
||||||
|
"Psycho Mouse",
|
||||||
|
"Gold Striker",
|
||||||
|
"RailBlazer"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:46:22.465Z"
|
||||||
|
},
|
||||||
|
"frontiercity": {
|
||||||
|
"rcdb_id": 4559,
|
||||||
|
"coasters": [
|
||||||
|
"Silver Bullet",
|
||||||
|
"Wildcat",
|
||||||
|
"Diamondback",
|
||||||
|
"Steel Lasso",
|
||||||
|
"Frankie's Mine Train"
|
||||||
|
],
|
||||||
|
"coasters_scraped_at": "2026-04-04T17:46:24.519Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
image: gitea.thewrightserver.net/josh/sixflagssupercalendar:latest
|
image: gitea.thewrightserver.net/josh/sixflagssupercalendar:web
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -9,5 +9,16 @@ services:
|
|||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
scraper:
|
||||||
|
image: gitea.thewrightserver.net/josh/sixflagssupercalendar:scraper
|
||||||
|
volumes:
|
||||||
|
- park_data:/app/data
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- TZ=America/New_York
|
||||||
|
- PARK_HOURS_STALENESS_HOURS=72
|
||||||
|
- COASTER_STALENESS_HOURS=720
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
park_data:
|
park_data:
|
||||||
|
|||||||
64
lib/coaster-match.ts
Normal file
64
lib/coaster-match.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Coaster name matching — shared between the Queue-Times scraper and tests.
|
||||||
|
*
|
||||||
|
* Queue-Times and RCDB use different name conventions:
|
||||||
|
* - Trademark symbols (™ ® ©)
|
||||||
|
* - Leading "THE " prefixes
|
||||||
|
* - Possessives ("Catwoman's" vs "Catwoman")
|
||||||
|
* - Subtitles added or dropped ("Apocalypse" vs "Apocalypse the Ride")
|
||||||
|
* - Space-split brand words ("BAT GIRL" vs "Batgirl")
|
||||||
|
* - Conjunction-joined compound rides ("Joker y Harley Quinn" ≠ "Joker")
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Words that join two ride names rather than extend one subtitle.
|
||||||
|
// When a prefix match is found and the next word is one of these,
|
||||||
|
// the longer name is a *different* ride, not a subtitle.
|
||||||
|
const CONJUNCTIONS = new Set(["y", "and", "&", "with", "de", "del", "e", "et"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a ride name for matching.
|
||||||
|
* Both sides (Queue-Times and RCDB) must be normalized with this function
|
||||||
|
* before any comparison so the transforms are symmetric.
|
||||||
|
*/
|
||||||
|
export function normalizeForMatch(name: string): string {
|
||||||
|
return name
|
||||||
|
.replace(/[\u2122\u00ae\u00a9™®©]/g, "") // strip ™ ® ©
|
||||||
|
.replace(/^the\s+/i, "") // strip leading "THE "
|
||||||
|
.replace(/['\u2019]s\b/gi, "") // strip possessives ('s / 's)
|
||||||
|
.replace(/[^\w\s]/g, " ") // all remaining punctuation → space
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the Queue-Times ride name matches an entry in the RCDB
|
||||||
|
* coaster set (which must be built with normalizeForMatch).
|
||||||
|
*
|
||||||
|
* Matching strategy (in order):
|
||||||
|
* 1. Exact normalized match.
|
||||||
|
* 2. Compact (space-stripped) match — catches "BAT GIRL" vs "Batgirl".
|
||||||
|
* 3. Prefix match — the shorter normalized name is a prefix of the longer,
|
||||||
|
* minimum 5 chars, unless the next word after the prefix is a conjunction
|
||||||
|
* (which signals a compound ride name, not a subtitle).
|
||||||
|
*/
|
||||||
|
export function isCoasterMatch(qtName: string, coasterSet: Set<string>): boolean {
|
||||||
|
const norm = normalizeForMatch(qtName);
|
||||||
|
if (coasterSet.has(norm)) return true;
|
||||||
|
|
||||||
|
const compact = norm.replace(/\s/g, "");
|
||||||
|
for (const c of coasterSet) {
|
||||||
|
// Compact comparison
|
||||||
|
if (compact.length >= 5 && c.replace(/\s/g, "") === compact) return true;
|
||||||
|
|
||||||
|
// Prefix comparison
|
||||||
|
const shorter = norm.length <= c.length ? norm : c;
|
||||||
|
const longer = norm.length <= c.length ? c : norm;
|
||||||
|
if (shorter.length >= 5 && longer.startsWith(shorter)) {
|
||||||
|
const nextWord = longer.slice(shorter.length).trim().split(" ")[0];
|
||||||
|
if (!CONJUNCTIONS.has(nextWord)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
28
lib/db.ts
28
lib/db.ts
@@ -46,6 +46,10 @@ export function upsertDay(
|
|||||||
hoursLabel?: string,
|
hoursLabel?: string,
|
||||||
specialType?: string
|
specialType?: string
|
||||||
) {
|
) {
|
||||||
|
// Today and future dates: full upsert — hours can change (e.g. weather delays,
|
||||||
|
// early closures) and the dateless API endpoint now returns today's live data.
|
||||||
|
//
|
||||||
|
// Past dates: INSERT-only — never overwrite once the day has passed.
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO park_days (park_id, date, is_open, hours_label, special_type, scraped_at)
|
INSERT INTO park_days (park_id, date, is_open, hours_label, special_type, scraped_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
@@ -54,6 +58,7 @@ export function upsertDay(
|
|||||||
hours_label = excluded.hours_label,
|
hours_label = excluded.hours_label,
|
||||||
special_type = excluded.special_type,
|
special_type = excluded.special_type,
|
||||||
scraped_at = excluded.scraped_at
|
scraped_at = excluded.scraped_at
|
||||||
|
WHERE park_days.date >= date('now')
|
||||||
`).run(parkId, date, isOpen ? 1 : 0, hoursLabel ?? null, specialType ?? null, new Date().toISOString());
|
`).run(parkId, date, isOpen ? 1 : 0, hoursLabel ?? null, specialType ?? null, new Date().toISOString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,16 +165,33 @@ export function getMonthCalendar(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True if the DB already has at least one row for this park+month. */
|
import { parseStalenessHours } from "./env";
|
||||||
const STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; // 1 week
|
const STALE_AFTER_MS = parseStalenessHours(process.env.PARK_HOURS_STALENESS_HOURS, 72) * 60 * 60 * 1000;
|
||||||
|
|
||||||
/** True if the DB has data for this park+month scraped within the last week. */
|
/**
|
||||||
|
* Returns true when the scraper should skip this park+month.
|
||||||
|
*
|
||||||
|
* Two reasons to skip:
|
||||||
|
* 1. The month is entirely in the past — the API will never return data for
|
||||||
|
* those dates again, so re-scraping wastes a call and risks nothing but
|
||||||
|
* wasted time. Historical records are preserved forever by upsertDay.
|
||||||
|
* 2. The month was scraped within the last 7 days — data is still fresh.
|
||||||
|
*/
|
||||||
export function isMonthScraped(
|
export function isMonthScraped(
|
||||||
db: Database.Database,
|
db: Database.Database,
|
||||||
parkId: string,
|
parkId: string,
|
||||||
year: number,
|
year: number,
|
||||||
month: number
|
month: number
|
||||||
): boolean {
|
): boolean {
|
||||||
|
// Compute the last calendar day of this month (avoids timezone issues).
|
||||||
|
const daysInMonth = new Date(year, month, 0).getDate();
|
||||||
|
const lastDay = `${year}-${String(month).padStart(2, "0")}-${String(daysInMonth).padStart(2, "0")}`;
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Past month — history is locked in, no API data available, always skip.
|
||||||
|
if (lastDay < today) return true;
|
||||||
|
|
||||||
|
// Current/future month — skip only if recently scraped.
|
||||||
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
||||||
const row = db
|
const row = db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
97
lib/env.ts
Normal file
97
lib/env.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Environment variable helpers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a staleness window from an env var string (interpreted as hours).
|
||||||
|
* Falls back to `defaultHours` when the value is missing, non-numeric,
|
||||||
|
* non-finite, or <= 0 — preventing NaN from silently breaking staleness checks.
|
||||||
|
*/
|
||||||
|
export function parseStalenessHours(envVar: string | undefined, defaultHours: number): number {
|
||||||
|
const parsed = parseInt(envVar ?? "", 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns today's date as YYYY-MM-DD using local wall-clock time with a 3 AM
|
||||||
|
* switchover. Before 3 AM local time we still consider it "yesterday", so the
|
||||||
|
* calendar doesn't flip to the next day at midnight while people are still out
|
||||||
|
* at the park.
|
||||||
|
*
|
||||||
|
* Important: `new Date().toISOString()` returns UTC, which causes the date to
|
||||||
|
* advance at 8 PM EDT (UTC-4) or 7 PM EST (UTC-5) — too early. This helper
|
||||||
|
* corrects that by using local year/month/day components and rolling back one
|
||||||
|
* day when the local hour is before 3.
|
||||||
|
*/
|
||||||
|
export function getTodayLocal(): string {
|
||||||
|
const now = new Date();
|
||||||
|
if (now.getHours() < 3) {
|
||||||
|
now.setDate(now.getDate() - 1);
|
||||||
|
}
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
|
const d = String(now.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the short timezone abbreviation for a given IANA timezone,
|
||||||
|
* e.g. "America/Los_Angeles" → "PDT" or "PST".
|
||||||
|
*/
|
||||||
|
export function getTimezoneAbbr(timezone: string): string {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: timezone,
|
||||||
|
timeZoneName: "short",
|
||||||
|
}).formatToParts(new Date());
|
||||||
|
return parts.find((p) => p.type === "timeZoneName")?.value ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the current time in the park's timezone is within
|
||||||
|
* the operating window (open time through 1 hour after close), based on
|
||||||
|
* a hoursLabel like "10am – 6pm". Falls back to true when unparseable.
|
||||||
|
*
|
||||||
|
* Uses the park's IANA timezone so a Pacific park's "10am" is correctly
|
||||||
|
* compared to Pacific time regardless of where the server is running.
|
||||||
|
*/
|
||||||
|
export function isWithinOperatingWindow(hoursLabel: string, timezone: string): boolean {
|
||||||
|
return getOperatingStatus(hoursLabel, timezone) !== "closed";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the park's current operating status relative to its scheduled hours:
|
||||||
|
* "open" — within the scheduled open window
|
||||||
|
* "closing" — past scheduled close but within the 1-hour wind-down buffer
|
||||||
|
* "closed" — outside the window entirely
|
||||||
|
* Falls back to "open" when the label can't be parsed.
|
||||||
|
*/
|
||||||
|
export function getOperatingStatus(hoursLabel: string, timezone: string): "open" | "closing" | "closed" {
|
||||||
|
const m = hoursLabel.match(
|
||||||
|
/^(\d+)(?::(\d+))?(am|pm)\s*[–-]\s*(\d+)(?::(\d+))?(am|pm)$/i
|
||||||
|
);
|
||||||
|
if (!m) return "open";
|
||||||
|
const toMinutes = (h: string, min: string | undefined, period: string) => {
|
||||||
|
let hours = parseInt(h, 10);
|
||||||
|
const minutes = min ? parseInt(min, 10) : 0;
|
||||||
|
if (period.toLowerCase() === "pm" && hours !== 12) hours += 12;
|
||||||
|
if (period.toLowerCase() === "am" && hours === 12) hours = 0;
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
};
|
||||||
|
const openMin = toMinutes(m[1], m[2], m[3]);
|
||||||
|
const closeMin = toMinutes(m[4], m[5], m[6]);
|
||||||
|
|
||||||
|
// Get the current time in the park's local timezone.
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: timezone,
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(new Date());
|
||||||
|
const h = parseInt(parts.find((p) => p.type === "hour")?.value ?? "0", 10);
|
||||||
|
const min = parseInt(parts.find((p) => p.type === "minute")?.value ?? "0", 10);
|
||||||
|
const nowMin = (h % 24) * 60 + min;
|
||||||
|
|
||||||
|
if (nowMin >= openMin && nowMin <= closeMin) return "open";
|
||||||
|
if (nowMin > closeMin && nowMin <= closeMin + 60) return "closing";
|
||||||
|
return "closed";
|
||||||
|
}
|
||||||
67
lib/park-meta.ts
Normal file
67
lib/park-meta.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* park-meta.json — persisted alongside the SQLite DB in data/
|
||||||
|
*
|
||||||
|
* This file stores per-park metadata that doesn't belong in the schedule DB:
|
||||||
|
* - rcdb_id: user-supplied RCDB park ID (fills into https://rcdb.com/{id}.htm)
|
||||||
|
* - coasters: list of operating roller coaster names scraped from RCDB
|
||||||
|
* - coasters_scraped_at: ISO timestamp of last RCDB scrape
|
||||||
|
*
|
||||||
|
* discover.ts: ensures every park has a skeleton entry (rcdb_id null by default)
|
||||||
|
* scrape.ts: populates coasters[] for parks with a known rcdb_id (30-day staleness)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const META_PATH = path.join(process.cwd(), "data", "park-meta.json");
|
||||||
|
|
||||||
|
export interface ParkMeta {
|
||||||
|
/** RCDB park page ID — user fills this in manually after discover creates the skeleton */
|
||||||
|
rcdb_id: number | null;
|
||||||
|
/** Operating roller coaster names scraped from RCDB */
|
||||||
|
coasters: string[];
|
||||||
|
/** ISO timestamp of when coasters was last scraped from RCDB */
|
||||||
|
coasters_scraped_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParkMetaMap = Record<string, ParkMeta>;
|
||||||
|
|
||||||
|
export function readParkMeta(): ParkMetaMap {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(META_PATH, "utf8")) as ParkMetaMap;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeParkMeta(meta: ParkMetaMap): void {
|
||||||
|
fs.mkdirSync(path.dirname(META_PATH), { recursive: true });
|
||||||
|
fs.writeFileSync(META_PATH, JSON.stringify(meta, null, 2) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default skeleton entry for a park that has never been configured. */
|
||||||
|
export function defaultParkMeta(): ParkMeta {
|
||||||
|
return { rcdb_id: null, coasters: [], coasters_scraped_at: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const COASTER_STALE_MS = parseStalenessHours(process.env.COASTER_STALENESS_HOURS, 720) * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Returns true when the coaster list needs to be re-scraped from RCDB. */
|
||||||
|
export function areCoastersStale(entry: ParkMeta): boolean {
|
||||||
|
if (!entry.coasters_scraped_at) return true;
|
||||||
|
return Date.now() - new Date(entry.coasters_scraped_at).getTime() > COASTER_STALE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
import { normalizeForMatch } from "./coaster-match";
|
||||||
|
export { normalizeForMatch as normalizeRideName } from "./coaster-match";
|
||||||
|
import { parseStalenessHours } from "./env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a Set of normalized coaster names for fast membership checks.
|
||||||
|
* Returns null when no coaster data exists for the park.
|
||||||
|
*/
|
||||||
|
export function getCoasterSet(parkId: string, meta: ParkMetaMap): Set<string> | null {
|
||||||
|
const entry = meta[parkId];
|
||||||
|
if (!entry || entry.coasters.length === 0) return null;
|
||||||
|
return new Set(entry.coasters.map(normalizeForMatch));
|
||||||
|
}
|
||||||
35
lib/queue-times-map.ts
Normal file
35
lib/queue-times-map.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Maps our internal park IDs to Queue-Times.com park IDs.
|
||||||
|
*
|
||||||
|
* API: https://queue-times.com/parks/{id}/queue_times.json
|
||||||
|
* Attribution required: "Powered by Queue-Times.com"
|
||||||
|
* See: https://queue-times.com/en-US/pages/api
|
||||||
|
*/
|
||||||
|
export const QUEUE_TIMES_IDS: Record<string, number> = {
|
||||||
|
// Six Flags branded parks
|
||||||
|
greatadventure: 37,
|
||||||
|
magicmountain: 32,
|
||||||
|
greatamerica: 38,
|
||||||
|
overgeorgia: 35,
|
||||||
|
overtexas: 34,
|
||||||
|
stlouis: 36,
|
||||||
|
fiestatexas: 39,
|
||||||
|
newengland: 43,
|
||||||
|
discoverykingdom: 33,
|
||||||
|
mexico: 47,
|
||||||
|
greatescape: 45,
|
||||||
|
darienlake: 281,
|
||||||
|
// Former Cedar Fair parks
|
||||||
|
cedarpoint: 50,
|
||||||
|
knotts: 61,
|
||||||
|
canadaswonderland: 58,
|
||||||
|
carowinds: 59,
|
||||||
|
kingsdominion: 62,
|
||||||
|
kingsisland: 60,
|
||||||
|
valleyfair: 68,
|
||||||
|
worldsoffun: 63,
|
||||||
|
miadventure: 70,
|
||||||
|
dorneypark: 69,
|
||||||
|
cagreatamerica: 57,
|
||||||
|
frontiercity: 282,
|
||||||
|
};
|
||||||
126
lib/scrapers/queuetimes.ts
Normal file
126
lib/scrapers/queuetimes.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Queue-Times.com live ride status scraper.
|
||||||
|
*
|
||||||
|
* API: https://queue-times.com/parks/{id}/queue_times.json
|
||||||
|
* Updates every 5 minutes while the park is operating.
|
||||||
|
* Attribution required per their terms: "Powered by Queue-Times.com"
|
||||||
|
* See: https://queue-times.com/en-US/pages/api
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isCoasterMatch } from "../coaster-match";
|
||||||
|
|
||||||
|
const BASE = "https://queue-times.com/parks";
|
||||||
|
|
||||||
|
const HEADERS = {
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
||||||
|
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||||
|
Accept: "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LiveRide {
|
||||||
|
name: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
waitMinutes: number;
|
||||||
|
lastUpdated: string; // ISO 8601
|
||||||
|
/** True when the ride name appears in the RCDB coaster list for this park. */
|
||||||
|
isCoaster: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveRidesResult {
|
||||||
|
rides: LiveRide[];
|
||||||
|
/** ISO timestamp of when we fetched the data */
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QTRide {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
is_open: boolean;
|
||||||
|
wait_time: number;
|
||||||
|
last_updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QTLand {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
rides: QTRide[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QTResponse {
|
||||||
|
lands: QTLand[];
|
||||||
|
rides: QTRide[]; // top-level rides (usually empty, rides live in lands)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch live ride open/closed status and wait times for a park.
|
||||||
|
*
|
||||||
|
* Returns null when:
|
||||||
|
* - The park has no Queue-Times mapping
|
||||||
|
* - The request fails
|
||||||
|
* - The response contains no rides
|
||||||
|
*
|
||||||
|
* Pass coasterNames (from RCDB static data) to classify rides accurately.
|
||||||
|
* Matching is case-insensitive. When coasterNames is null no ride is
|
||||||
|
* classified as a coaster and the "Coasters only" toggle is hidden.
|
||||||
|
*
|
||||||
|
* Pass revalidate (seconds) to control Next.js ISR cache lifetime.
|
||||||
|
* Defaults to 300s (5 min) to match Queue-Times update frequency.
|
||||||
|
*/
|
||||||
|
export async function fetchLiveRides(
|
||||||
|
queueTimesId: number,
|
||||||
|
coasterNames: Set<string> | null = null,
|
||||||
|
revalidate = 300,
|
||||||
|
): Promise<LiveRidesResult | null> {
|
||||||
|
const url = `${BASE}/${queueTimesId}/queue_times.json`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: HEADERS,
|
||||||
|
next: { revalidate },
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
} as RequestInit & { next: { revalidate: number } });
|
||||||
|
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
const json = (await res.json()) as QTResponse;
|
||||||
|
|
||||||
|
const rides: LiveRide[] = [];
|
||||||
|
|
||||||
|
for (const land of json.lands ?? []) {
|
||||||
|
for (const r of land.rides ?? []) {
|
||||||
|
if (!r.name) continue;
|
||||||
|
rides.push({
|
||||||
|
name: r.name,
|
||||||
|
isOpen: r.is_open,
|
||||||
|
waitMinutes: r.wait_time ?? 0,
|
||||||
|
lastUpdated: r.last_updated,
|
||||||
|
isCoaster: coasterNames ? isCoasterMatch(r.name, coasterNames) : false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also capture any top-level rides (rare but possible)
|
||||||
|
for (const r of json.rides ?? []) {
|
||||||
|
if (!r.name) continue;
|
||||||
|
rides.push({
|
||||||
|
name: r.name,
|
||||||
|
isOpen: r.is_open,
|
||||||
|
waitMinutes: r.wait_time ?? 0,
|
||||||
|
lastUpdated: r.last_updated,
|
||||||
|
isCoaster: coasterNames ? isCoasterMatch(r.name, coasterNames) : false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rides.length === 0) return null;
|
||||||
|
|
||||||
|
// Open rides first, then alphabetical within each group
|
||||||
|
rides.sort((a, b) => {
|
||||||
|
if (a.isOpen !== b.isOpen) return a.isOpen ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { rides, fetchedAt: new Date().toISOString() };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
lib/scrapers/rcdb.ts
Normal file
91
lib/scrapers/rcdb.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* RCDB (Roller Coaster DataBase) scraper.
|
||||||
|
*
|
||||||
|
* Fetches a park's RCDB page (https://rcdb.com/{id}.htm) and extracts the
|
||||||
|
* names of operating roller coasters from the "Operating Roller Coasters"
|
||||||
|
* section.
|
||||||
|
*
|
||||||
|
* RCDB has no public API. This scraper reads the static HTML page.
|
||||||
|
* Please scrape infrequently (30-day staleness window) to be respectful.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE = "https://rcdb.com";
|
||||||
|
|
||||||
|
const HEADERS = {
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
||||||
|
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||||
|
Accept: "text/html,application/xhtml+xml",
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrape operating roller coaster names for a park.
|
||||||
|
*
|
||||||
|
* Returns an array of coaster names on success, or null when the page
|
||||||
|
* cannot be fetched or contains no operating coasters.
|
||||||
|
*/
|
||||||
|
export async function scrapeRcdbCoasters(rcdbId: number): Promise<string[] | null> {
|
||||||
|
const url = `${BASE}/${rcdbId}.htm`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers: HEADERS, signal: AbortSignal.timeout(15_000) });
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(` RCDB ${rcdbId}: HTTP ${res.status}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const html = await res.text();
|
||||||
|
return parseOperatingCoasters(html);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` RCDB ${rcdbId}: ${err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse operating roller coaster names from RCDB park page HTML.
|
||||||
|
*
|
||||||
|
* RCDB park pages list coasters in sections bounded by <section> tags.
|
||||||
|
* The operating section heading looks like:
|
||||||
|
* <h4>Operating Roller Coasters: <a href="...">16</a></h4>
|
||||||
|
*
|
||||||
|
* Each coaster is an <a> link to its detail page with an unquoted href:
|
||||||
|
* <td data-sort="Batman The Ride"><a href=/5.htm>Batman The Ride</a>
|
||||||
|
*
|
||||||
|
* We extract only those links (href=/DIGITS.htm) from within the
|
||||||
|
* operating section, stopping at the next <section> tag.
|
||||||
|
*/
|
||||||
|
function parseOperatingCoasters(html: string): string[] {
|
||||||
|
// Find the "Operating Roller Coasters" section heading.
|
||||||
|
const opIdx = html.search(/Operating\s+Roller\s+Coasters/i);
|
||||||
|
if (opIdx === -1) return [];
|
||||||
|
|
||||||
|
// The section ends at the next <section> tag (e.g. "Defunct Roller Coasters").
|
||||||
|
const after = html.slice(opIdx);
|
||||||
|
const nextSection = after.search(/<section\b/i);
|
||||||
|
const sectionHtml = nextSection > 0 ? after.slice(0, nextSection) : after;
|
||||||
|
|
||||||
|
// Extract coaster names from links to RCDB detail pages.
|
||||||
|
// RCDB uses unquoted href attributes: href=/1234.htm
|
||||||
|
// General links (/g.htm, /r.htm, /location.htm, etc.) won't match \d+\.htm.
|
||||||
|
const names: string[] = [];
|
||||||
|
const linkPattern = /<a\s[^>]*href=["']?\/(\d+)\.htm["']?[^>]*>([^<]+)<\/a>/gi;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = linkPattern.exec(sectionHtml)) !== null) {
|
||||||
|
const name = decodeHtmlEntities(match[2].trim());
|
||||||
|
if (name) names.push(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate while preserving order
|
||||||
|
return [...new Set(names)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntities(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)))
|
||||||
|
.replace(/&[a-z]+;/gi, "");
|
||||||
|
}
|
||||||
@@ -107,12 +107,12 @@ async function fetchApi(
|
|||||||
): Promise<ApiResponse> {
|
): Promise<ApiResponse> {
|
||||||
const fetchOpts: RequestInit & { next?: { revalidate: number } } = { headers: HEADERS };
|
const fetchOpts: RequestInit & { next?: { revalidate: number } } = { headers: HEADERS };
|
||||||
if (revalidate !== undefined) fetchOpts.next = { revalidate };
|
if (revalidate !== undefined) fetchOpts.next = { revalidate };
|
||||||
const res = await fetch(url, fetchOpts);
|
const res = await fetch(url, { ...fetchOpts, signal: AbortSignal.timeout(15_000) });
|
||||||
|
|
||||||
if (res.status === 429 || res.status === 503) {
|
if (res.status === 429 || res.status === 503) {
|
||||||
const retryAfter = res.headers.get("Retry-After");
|
const retryAfter = res.headers.get("Retry-After");
|
||||||
const waitMs = retryAfter
|
const waitMs = retryAfter
|
||||||
? parseInt(retryAfter) * 1000
|
? Math.min(parseInt(retryAfter, 10) * 1000, 5 * 60 * 1000) // cap at 5 min
|
||||||
: BASE_BACKOFF_MS * Math.pow(2, attempt);
|
: BASE_BACKOFF_MS * Math.pow(2, attempt);
|
||||||
console.log(
|
console.log(
|
||||||
` [rate-limited] HTTP ${res.status} — waiting ${waitMs / 1000}s (attempt ${attempt + 1}/${MAX_RETRIES})`
|
` [rate-limited] HTTP ${res.status} — waiting ${waitMs / 1000}s (attempt ${attempt + 1}/${MAX_RETRIES})`
|
||||||
@@ -166,13 +166,48 @@ function apiDateToIso(apiDate: string): string {
|
|||||||
return `${yyyy}-${mm}-${dd}`;
|
return `${yyyy}-${mm}-${dd}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse a single ApiDay into a DayResult. Shared by scrapeMonth and fetchToday. */
|
||||||
|
function parseApiDay(d: ApiDay): DayResult {
|
||||||
|
const date = parseApiDate(d.date);
|
||||||
|
const operating =
|
||||||
|
d.operatings?.find((o) => o.operatingTypeName === "Park") ??
|
||||||
|
d.operatings?.[0];
|
||||||
|
const item = operating?.items?.[0];
|
||||||
|
const hoursLabel =
|
||||||
|
item?.timeFrom && item?.timeTo
|
||||||
|
? `${fmt24(item.timeFrom)} – ${fmt24(item.timeTo)}`
|
||||||
|
: undefined;
|
||||||
|
const isPassholderPreview = d.events?.some((e) =>
|
||||||
|
e.extEventName.toLowerCase().includes("passholder preview")
|
||||||
|
) ?? false;
|
||||||
|
const isBuyout = item?.isBuyout ?? false;
|
||||||
|
const isOpen = !d.isParkClosed && hoursLabel !== undefined && (!isBuyout || isPassholderPreview);
|
||||||
|
const specialType: DayResult["specialType"] = isPassholderPreview ? "passholder_preview" : undefined;
|
||||||
|
return { date, isOpen, hoursLabel: isOpen ? hoursLabel : undefined, specialType };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch ride operating status for a given date.
|
* Fetch today's operating data directly (no date param = API returns today).
|
||||||
|
* Pass `revalidate` (seconds) for Next.js ISR caching; omit for a fully fresh fetch.
|
||||||
|
*/
|
||||||
|
export async function fetchToday(apiId: number, revalidate?: number): Promise<DayResult | null> {
|
||||||
|
try {
|
||||||
|
const url = `${API_BASE}/${apiId}`;
|
||||||
|
const raw = await fetchApi(url, 0, 0, revalidate);
|
||||||
|
if (!raw.dates.length) return null;
|
||||||
|
return parseApiDay(raw.dates[0]);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch ride operating status for a given date. Used as a fallback when
|
||||||
|
* Queue-Times live data is unavailable.
|
||||||
*
|
*
|
||||||
* The Six Flags API drops dates that have already started (including today),
|
* The monthly API endpoint (`?date=YYYYMM`) may not include today; use
|
||||||
* returning only tomorrow onwards. When the requested date is missing, we fall
|
* `fetchToday(apiId)` to get today's park hours directly. The fallback
|
||||||
* back to the nearest available upcoming date in the same month's response so
|
* chain here will find the nearest upcoming date if an exact match is missing.
|
||||||
* the UI can still show a useful (if approximate) schedule.
|
|
||||||
*
|
*
|
||||||
* Returns null if no ride data could be found at all (API error, pre-season,
|
* Returns null if no ride data could be found at all (API error, pre-season,
|
||||||
* no venues in response).
|
* no venues in response).
|
||||||
@@ -286,30 +321,7 @@ export async function scrapeMonth(
|
|||||||
|
|
||||||
const data = await fetchApi(url);
|
const data = await fetchApi(url);
|
||||||
|
|
||||||
return data.dates.map((d): DayResult => {
|
return data.dates.map(parseApiDay);
|
||||||
const date = parseApiDate(d.date);
|
|
||||||
// Prefer the "Park" operating entry; fall back to first entry
|
|
||||||
const operating =
|
|
||||||
d.operatings?.find((o) => o.operatingTypeName === "Park") ??
|
|
||||||
d.operatings?.[0];
|
|
||||||
const item = operating?.items?.[0];
|
|
||||||
const hoursLabel =
|
|
||||||
item?.timeFrom && item?.timeTo
|
|
||||||
? `${fmt24(item.timeFrom)} – ${fmt24(item.timeTo)}`
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const isPassholderPreview = d.events?.some((e) =>
|
|
||||||
e.extEventName.toLowerCase().includes("passholder preview")
|
|
||||||
) ?? false;
|
|
||||||
|
|
||||||
const isBuyout = item?.isBuyout ?? false;
|
|
||||||
|
|
||||||
// Buyout days are private events — treat as closed unless it's a passholder preview
|
|
||||||
const isOpen = !d.isParkClosed && hoursLabel !== undefined && (!isBuyout || isPassholderPreview);
|
|
||||||
const specialType: DayResult["specialType"] = isPassholderPreview ? "passholder_preview" : undefined;
|
|
||||||
|
|
||||||
return { date, isOpen, hoursLabel: isOpen ? hoursLabel : undefined, specialType };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,9 +1,34 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const CSP = [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' 'unsafe-inline'", // Next.js requires unsafe-inline for hydration
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' data:",
|
||||||
|
"font-src 'self'",
|
||||||
|
"connect-src 'self' https://queue-times.com",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
].join("; ");
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// better-sqlite3 is a native module — must not be bundled by webpack
|
// better-sqlite3 is a native module — must not be bundled by webpack
|
||||||
serverExternalPackages: ["better-sqlite3"],
|
serverExternalPackages: ["better-sqlite3"],
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/(.*)",
|
||||||
|
headers: [
|
||||||
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
|
{ key: "X-Frame-Options", value: "DENY" },
|
||||||
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||||
|
{ key: "Permissions-Policy", value: "geolocation=(), microphone=(), camera=()" },
|
||||||
|
{ key: "Content-Security-Policy", value: CSP },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"scrape": "tsx scripts/scrape.ts",
|
"scrape": "tsx scripts/scrape.ts",
|
||||||
"scrape:force": "tsx scripts/scrape.ts --rescrape",
|
"scrape:force": "tsx scripts/scrape.ts --rescrape",
|
||||||
"discover": "tsx scripts/discover.ts",
|
"discover": "tsx scripts/discover.ts",
|
||||||
"debug": "tsx scripts/debug.ts"
|
"debug": "tsx scripts/debug.ts",
|
||||||
|
"test": "tsx --test tests/*.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { chromium } from "playwright";
|
|||||||
import { openDb, getApiId, setApiId, type DbInstance } from "../lib/db";
|
import { openDb, getApiId, setApiId, type DbInstance } from "../lib/db";
|
||||||
import { PARKS } from "../lib/parks";
|
import { PARKS } from "../lib/parks";
|
||||||
import { fetchParkInfo, isMainThemePark } from "../lib/scrapers/sixflags";
|
import { fetchParkInfo, isMainThemePark } from "../lib/scrapers/sixflags";
|
||||||
|
import { readParkMeta, writeParkMeta, defaultParkMeta } from "../lib/park-meta";
|
||||||
|
|
||||||
const CLOUDFRONT_PATTERN = /operating-hours\/park\/(\d+)/;
|
const CLOUDFRONT_PATTERN = /operating-hours\/park\/(\d+)/;
|
||||||
|
|
||||||
@@ -115,7 +116,6 @@ async function main() {
|
|||||||
// Fetch full info to store name/abbreviation
|
// Fetch full info to store name/abbreviation
|
||||||
const info = await fetchParkInfo(apiId);
|
const info = await fetchParkInfo(apiId);
|
||||||
setApiId(db, park.id, apiId, info?.parkAbbreviation, info?.parkName);
|
setApiId(db, park.id, apiId, info?.parkAbbreviation, info?.parkName);
|
||||||
console.log(`done (ID ${apiId})`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(`ERROR: ${err}`);
|
console.log(`ERROR: ${err}`);
|
||||||
}
|
}
|
||||||
@@ -124,11 +124,39 @@ async function main() {
|
|||||||
await new Promise((r) => setTimeout(r, 2000));
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Ensure park-meta.json has a skeleton entry for every park ────────────
|
||||||
|
// Users fill in rcdb_id manually; scrape.ts populates coasters[] from RCDB.
|
||||||
|
const meta = readParkMeta();
|
||||||
|
let metaChanged = false;
|
||||||
|
|
||||||
|
for (const park of PARKS) {
|
||||||
|
if (!meta[park.id]) {
|
||||||
|
meta[park.id] = defaultParkMeta();
|
||||||
|
metaChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove entries for parks no longer in the registry
|
||||||
|
for (const id of Object.keys(meta)) {
|
||||||
|
if (!PARKS.find((p) => p.id === id)) {
|
||||||
|
delete meta[id];
|
||||||
|
metaChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metaChanged) {
|
||||||
|
writeParkMeta(meta);
|
||||||
|
console.log("\nUpdated data/park-meta.json");
|
||||||
|
console.log(" → Set rcdb_id for each park to enable the coaster filter.");
|
||||||
|
console.log(" Find a park's RCDB ID from: https://rcdb.com (the number in the URL).");
|
||||||
|
}
|
||||||
|
|
||||||
// Print summary
|
// Print summary
|
||||||
console.log("\n── Discovered IDs ──");
|
console.log("\n── Discovered IDs ──");
|
||||||
for (const park of PARKS) {
|
for (const park of PARKS) {
|
||||||
const id = getApiId(db, park.id);
|
const id = getApiId(db, park.id);
|
||||||
console.log(` ${park.id.padEnd(30)} ${id ?? "NOT FOUND"}`);
|
const rcdbId = meta[park.id]?.rcdb_id;
|
||||||
|
const rcdbStr = rcdbId ? `rcdb:${rcdbId}` : "rcdb:?";
|
||||||
|
console.log(` ${park.id.padEnd(30)} api:${String(id ?? "?").padEnd(8)} ${rcdbStr}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
45
scripts/scrape-schedule.sh
Normal file
45
scripts/scrape-schedule.sh
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Nightly scraper scheduler — runs inside the Docker scraper service.
|
||||||
|
#
|
||||||
|
# Behaviour:
|
||||||
|
# 1. Runs an initial scrape immediately on container start.
|
||||||
|
# 2. Sleeps until 3:00 AM (container timezone, set via TZ env var).
|
||||||
|
# 3. Runs the scraper, then sleeps until the next 3:00 AM, forever.
|
||||||
|
#
|
||||||
|
# Timezone: set TZ in the scraper service environment to control when
|
||||||
|
# "3am" is (e.g. TZ=America/New_York). Defaults to UTC if unset.
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[scheduler] $(date '+%Y-%m-%d %H:%M %Z') — $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_scrape() {
|
||||||
|
log "Starting scrape"
|
||||||
|
if npm run scrape; then
|
||||||
|
log "Scrape completed"
|
||||||
|
else
|
||||||
|
log "Scrape failed — will retry at next scheduled time"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
seconds_until_3am() {
|
||||||
|
now=$(date +%s)
|
||||||
|
# Try today's 3am first; if already past, use tomorrow's.
|
||||||
|
target=$(date -d "today 03:00" +%s)
|
||||||
|
if [ "$now" -ge "$target" ]; then
|
||||||
|
target=$(date -d "tomorrow 03:00" +%s)
|
||||||
|
fi
|
||||||
|
echo $((target - now))
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Run immediately on startup ────────────────────────────────────────────────
|
||||||
|
run_scrape
|
||||||
|
|
||||||
|
# ── Nightly loop ──────────────────────────────────────────────────────────────
|
||||||
|
while true; do
|
||||||
|
wait=$(seconds_until_3am)
|
||||||
|
next=$(date -d "now + ${wait} seconds" '+%Y-%m-%d %H:%M %Z')
|
||||||
|
log "Next scrape in $((wait / 3600))h $((( wait % 3600) / 60))m (${next})"
|
||||||
|
sleep "$wait"
|
||||||
|
run_scrape
|
||||||
|
done
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
|
|
||||||
import { openDb, upsertDay, getApiId, isMonthScraped } from "../lib/db";
|
import { openDb, upsertDay, getApiId, isMonthScraped } from "../lib/db";
|
||||||
import { PARKS } from "../lib/parks";
|
import { PARKS } from "../lib/parks";
|
||||||
import { scrapeMonth, RateLimitError } from "../lib/scrapers/sixflags";
|
import { scrapeMonth, fetchToday, RateLimitError } from "../lib/scrapers/sixflags";
|
||||||
|
import { readParkMeta, writeParkMeta, areCoastersStale } from "../lib/park-meta";
|
||||||
|
import { scrapeRcdbCoasters } from "../lib/scrapers/rcdb";
|
||||||
|
|
||||||
const YEAR = 2026;
|
const YEAR = 2026;
|
||||||
const MONTHS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
const MONTHS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
||||||
@@ -98,7 +100,62 @@ async function main() {
|
|||||||
console.log(`\n ${totalFetched} fetched ${totalSkipped} skipped ${totalErrors} errors`);
|
console.log(`\n ${totalFetched} fetched ${totalSkipped} skipped ${totalErrors} errors`);
|
||||||
if (totalErrors > 0) console.log(" Re-run to retry failed months.");
|
if (totalErrors > 0) console.log(" Re-run to retry failed months.");
|
||||||
|
|
||||||
|
// ── Today scrape (always fresh — dateless endpoint returns current day) ────
|
||||||
|
console.log("\n── Today's data ──");
|
||||||
|
for (const park of ready) {
|
||||||
|
const apiId = getApiId(db, park.id)!;
|
||||||
|
process.stdout.write(` ${park.shortName.padEnd(22)} `);
|
||||||
|
try {
|
||||||
|
const today = await fetchToday(apiId);
|
||||||
|
if (today) {
|
||||||
|
upsertDay(db, park.id, today.date, today.isOpen, today.hoursLabel, today.specialType);
|
||||||
|
console.log(today.isOpen ? `open ${today.hoursLabel ?? ""}` : "closed");
|
||||||
|
} else {
|
||||||
|
console.log("no data");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.log("error");
|
||||||
|
}
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
|
|
||||||
|
// ── RCDB coaster scrape (30-day staleness) ────────────────────────────────
|
||||||
|
const meta = readParkMeta();
|
||||||
|
const rcdbParks = PARKS.filter((p) => {
|
||||||
|
const entry = meta[p.id];
|
||||||
|
return entry?.rcdb_id && (FORCE || areCoastersStale(entry));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rcdbParks.length === 0) {
|
||||||
|
console.log("\nCoaster data up to date.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n── RCDB coaster scrape — ${rcdbParks.length} park(s) ──`);
|
||||||
|
|
||||||
|
for (const park of rcdbParks) {
|
||||||
|
const entry = meta[park.id];
|
||||||
|
const rcdbId = entry.rcdb_id!;
|
||||||
|
process.stdout.write(` ${park.shortName.padEnd(30)} `);
|
||||||
|
|
||||||
|
const coasters = await scrapeRcdbCoasters(rcdbId);
|
||||||
|
if (coasters === null) {
|
||||||
|
console.log("FAILED");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.coasters = coasters;
|
||||||
|
entry.coasters_scraped_at = new Date().toISOString();
|
||||||
|
console.log(`${coasters.length} coasters`);
|
||||||
|
|
||||||
|
// Polite delay between RCDB requests
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
}
|
||||||
|
|
||||||
|
writeParkMeta(meta);
|
||||||
|
console.log(" Saved to data/park-meta.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
|
|||||||
51
tests/coaster-matching.test.ts
Normal file
51
tests/coaster-matching.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Coaster name matching tests.
|
||||||
|
*
|
||||||
|
* Each entry is a real case found between Queue-Times and RCDB names.
|
||||||
|
* Add new cases here when fixing a mismatch or false positive.
|
||||||
|
*
|
||||||
|
* Run with: npm test
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { isCoasterMatch, normalizeForMatch } from "../lib/coaster-match";
|
||||||
|
|
||||||
|
function set(...rcdbNames: string[]): Set<string> {
|
||||||
|
return new Set(rcdbNames.map(normalizeForMatch));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Should match ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SHOULD_MATCH: [qtName: string, rcdbName: string, park: string][] = [
|
||||||
|
["BATMAN™ The Ride", "Batman The Ride", "Over Georgia / Magic Mountain"],
|
||||||
|
["THE RIDDLER Mindbender", "Riddler Mindbender", "Over Georgia"],
|
||||||
|
["THE RIDDLER™'s Revenge", "Riddler's Revenge", "Magic Mountain"],
|
||||||
|
["CATWOMAN™ Whip", "Catwoman's Whip", "New England"],
|
||||||
|
["SUPERMAN™: Ultimate Flight", "Superman - Ultimate Flight", "Over Georgia"],
|
||||||
|
["THE JOKER™ Funhouse Coaster", "Joker Funhouse Coaster", "Over Georgia"],
|
||||||
|
["The Great American Scream Machine", "Great American Scream Machine", "Over Georgia"],
|
||||||
|
["Apocalypse", "Apocalypse the Ride", "Magic Mountain"],
|
||||||
|
["The New Revolution - Classic", "New Revolution", "Magic Mountain"],
|
||||||
|
["SCREAM", "Scream!", "Magic Mountain"],
|
||||||
|
["BAT GIRL™: Coaster Chase", "Batgirl Coaster Chase", "Fiesta Texas"],
|
||||||
|
["THE JOKER™ 4D Free Fly Coaster", "Joker", "New England"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [qt, rcdb, park] of SHOULD_MATCH) {
|
||||||
|
test(`match: "${qt}" = "${rcdb}" (${park})`, () => {
|
||||||
|
assert.ok(isCoasterMatch(qt, set(rcdb)), `Expected match`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Should NOT match (false positives) ───────────────────────────────────────
|
||||||
|
|
||||||
|
const SHOULD_NOT_MATCH: [qtName: string, rcdbName: string, park: string][] = [
|
||||||
|
["Joker y Harley Quinn", "Joker", "Six Flags Mexico"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [qt, rcdb, park] of SHOULD_NOT_MATCH) {
|
||||||
|
test(`no match: "${qt}" ≠ "${rcdb}" (${park})`, () => {
|
||||||
|
assert.ok(!isCoasterMatch(qt, set(rcdb)), `Expected no match`);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user