Compare commits
69 Commits
da083c125c
...
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 |
@@ -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"]
|
||||||
|
|||||||
97
README.md
97
README.md
@@ -31,6 +31,13 @@ The park detail page shows ride open/closed status using a two-tier approach:
|
|||||||
|
|
||||||
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.
|
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
|
||||||
@@ -56,7 +63,7 @@ Scrape operating hours for the full year:
|
|||||||
npm run scrape
|
npm run scrape
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
||||||
@@ -72,6 +79,12 @@ npm run debug -- --park kingsisland --date 2026-06-15
|
|||||||
|
|
||||||
Output is printed to the terminal and saved to `debug/{parkId}_{date}.txt`.
|
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
|
||||||
@@ -84,31 +97,93 @@ Open [http://localhost:3000](http://localhost:3000). Navigate weeks with the `
|
|||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
The app uses Next.js standalone output. The SQLite database is stored in a Docker volume at `/app/data`.
|
The app ships as two separate Docker images that share a named volume for the SQLite database:
|
||||||
|
|
||||||
|
| Image | Tag | Purpose |
|
||||||
|
|-------|-----|---------|
|
||||||
|
| Next.js web server | `:web` | Reads DB, serves content. No scraping tools. |
|
||||||
|
| Scraper + scheduler | `:scraper` | Nightly data refresh. No web server. |
|
||||||
|
|
||||||
|
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 run directly against the container's volume:
|
### Updating
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec web npm run discover
|
docker compose pull && docker compose up -d
|
||||||
docker compose exec web npm run scrape
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Or as a one-off 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 scrape
|
|
||||||
|
Force re-scrape of all data (ignores staleness):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec scraper npm run scrape:force
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data Refresh
|
## Data Refresh
|
||||||
|
|
||||||
The scraper skips any park + month combination scraped within the last 7 days. Run `npm run scrape` on a weekly schedule to keep data current. Parks or months not yet in the database show a `—` placeholder; parks with no open days in the displayed week are hidden from the calendar automatically.
|
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.
|
||||||
|
|
||||||
|
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 ───────────────────────────────────────── */
|
||||||
|
|||||||
199
app/page.tsx
199
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,100 +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 16px 10px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: 12,
|
|
||||||
}}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: "1.1rem",
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--color-text)",
|
|
||||||
letterSpacing: "-0.02em",
|
|
||||||
}}>
|
|
||||||
Thoosie Calendar
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span style={{
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
borderRadius: 20,
|
|
||||||
padding: "3px 10px",
|
|
||||||
fontSize: "0.7rem",
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
fontWeight: 500,
|
|
||||||
}}>
|
|
||||||
{visibleParks.length} of {PARKS.length} parks open
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 2: Week nav + legend (legend hidden on mobile) */}
|
|
||||||
<div style={{
|
|
||||||
padding: "8px 16px 10px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: 16,
|
|
||||||
borderTop: "1px solid var(--color-border-subtle)",
|
|
||||||
}}>
|
|
||||||
<WeekNav
|
|
||||||
weekStart={weekStart}
|
weekStart={weekStart}
|
||||||
weekDates={weekDates}
|
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}
|
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,13 +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 { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
||||||
|
import { fetchToday } from "@/lib/scrapers/sixflags";
|
||||||
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
|
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
|
||||||
|
import { readParkMeta, getCoasterSet } from "@/lib/park-meta";
|
||||||
import { 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, LiveRide } from "@/lib/scrapers/queuetimes";
|
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 }>;
|
||||||
@@ -21,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) {
|
||||||
@@ -32,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();
|
||||||
@@ -40,22 +45,50 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
|||||||
const apiId = getApiId(db, id);
|
const apiId = getApiId(db, id);
|
||||||
db.close();
|
db.close();
|
||||||
|
|
||||||
const todayData = monthData[today];
|
// Prefer live today data from the Six Flags API (5-min ISR cache) so that
|
||||||
|
// weather delays and hour changes surface immediately rather than showing
|
||||||
|
// stale DB values. Fall back to DB if the API call fails.
|
||||||
|
const liveToday = apiId !== null ? await fetchToday(apiId, 300).catch(() => null) : null;
|
||||||
|
const todayData = liveToday
|
||||||
|
? { isOpen: liveToday.isOpen, hoursLabel: liveToday.hoursLabel ?? null, specialType: liveToday.specialType ?? null }
|
||||||
|
: monthData[today];
|
||||||
const parkOpenToday = todayData?.isOpen && todayData?.hoursLabel;
|
const parkOpenToday = todayData?.isOpen && todayData?.hoursLabel;
|
||||||
|
|
||||||
// ── Ride data: try live Queue-Times first, fall back to schedule ──────────
|
// ── Ride data: try live Queue-Times first, fall back to schedule ──────────
|
||||||
const queueTimesId = QUEUE_TIMES_IDS[id];
|
const queueTimesId = QUEUE_TIMES_IDS[id];
|
||||||
|
const parkMeta = readParkMeta();
|
||||||
|
const coasterSet = getCoasterSet(id, parkMeta);
|
||||||
|
|
||||||
let liveRides: LiveRidesResult | null = null;
|
let liveRides: LiveRidesResult | null = null;
|
||||||
let ridesResult: RidesFetchResult | null = null;
|
let ridesResult: RidesFetchResult | null = null;
|
||||||
|
|
||||||
|
// Determine if we're within the 1h-before-open to 1h-after-close window.
|
||||||
|
const withinWindow = todayData?.hoursLabel
|
||||||
|
? isWithinOperatingWindow(todayData.hoursLabel, park.timezone)
|
||||||
|
: false;
|
||||||
|
|
||||||
if (queueTimesId) {
|
if (queueTimesId) {
|
||||||
liveRides = await fetchLiveRides(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 })),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only hit the schedule API as a fallback when live data is unavailable
|
// Weather delay: park is within operating hours but queue-times shows 0 open rides
|
||||||
|
const isWeatherDelay =
|
||||||
|
withinWindow &&
|
||||||
|
liveRides !== null &&
|
||||||
|
liveRides.rides.length > 0 &&
|
||||||
|
liveRides.rides.every((r) => !r.isOpen);
|
||||||
|
|
||||||
|
// Only hit the schedule API as a fallback when Queue-Times live data is unavailable.
|
||||||
if (!liveRides && apiId !== null) {
|
if (!liveRides && apiId !== null) {
|
||||||
// Note: the API drops today's date from its response (only returns future dates),
|
|
||||||
// so scrapeRidesForDay may fall back to the nearest upcoming date.
|
|
||||||
ridesResult = await scrapeRidesForDay(apiId, today);
|
ridesResult = await scrapeRidesForDay(apiId, today);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,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}
|
||||||
@@ -95,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>
|
||||||
@@ -105,12 +126,31 @@ 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 ? (
|
{liveRides ? (
|
||||||
<LiveBadge />
|
<LiveBadge />
|
||||||
@@ -126,9 +166,10 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
|||||||
</SectionHeading>
|
</SectionHeading>
|
||||||
|
|
||||||
{liveRides ? (
|
{liveRides ? (
|
||||||
<LiveRideList
|
<LiveRidePanel
|
||||||
liveRides={liveRides}
|
liveRides={liveRides}
|
||||||
parkOpenToday={!!parkOpenToday}
|
parkOpenToday={!!parkOpenToday}
|
||||||
|
isWeatherDelay={isWeatherDelay}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RideList
|
<RideList
|
||||||
@@ -153,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)",
|
||||||
@@ -170,9 +211,12 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -207,158 +251,6 @@ function LiveBadge() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Live ride list (Queue-Times data) ──────────────────────────────────────
|
|
||||||
|
|
||||||
function LiveRideList({
|
|
||||||
liveRides,
|
|
||||||
parkOpenToday,
|
|
||||||
}: {
|
|
||||||
liveRides: LiveRidesResult;
|
|
||||||
parkOpenToday: boolean;
|
|
||||||
}) {
|
|
||||||
const { rides } = liveRides;
|
|
||||||
const openRides = rides.filter((r) => r.isOpen);
|
|
||||||
const closedRides = rides.filter((r) => !r.isOpen);
|
|
||||||
const anyOpen = openRides.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Summary badge row */}
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 16, flexWrap: "wrap" }}>
|
|
||||||
{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)",
|
|
||||||
}}>
|
|
||||||
{openRides.length} open
|
|
||||||
</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)",
|
|
||||||
}}>
|
|
||||||
{parkOpenToday ? "Not open yet — check back soon" : "No rides open"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{anyOpen && 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)",
|
|
||||||
}}>
|
|
||||||
{closedRides.length} closed / down
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Two-column grid */}
|
|
||||||
<div style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
|
||||||
gap: 6,
|
|
||||||
}}>
|
|
||||||
{openRides.map((ride) => <LiveRideRow key={ride.name} ride={ride} />)}
|
|
||||||
{closedRides.map((ride) => <LiveRideRow key={ride.name} ride={ride} />)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Attribution — required by Queue-Times terms */}
|
|
||||||
<div style={{
|
|
||||||
marginTop: 20,
|
|
||||||
fontSize: "0.68rem",
|
|
||||||
color: "var(--color-text-dim)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 4,
|
|
||||||
}}>
|
|
||||||
Powered by{" "}
|
|
||||||
<a
|
|
||||||
href="https://queue-times.com"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{ color: "var(--color-text-muted)", textDecoration: "underline" }}
|
|
||||||
>
|
|
||||||
Queue-Times.com
|
|
||||||
</a>
|
|
||||||
{" "}· Updates every 5 minutes
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LiveRideRow({ 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 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Schedule ride list (Six Flags operating-hours API fallback) ────────────
|
// ── Schedule ride list (Six Flags operating-hours API fallback) ────────────
|
||||||
|
|
||||||
function RideList({
|
function RideList({
|
||||||
@@ -465,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,9 +8,14 @@ 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: 20, paddingTop: 14 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 20, paddingTop: 14 }}>
|
||||||
{Array.from(grouped.entries()).map(([region, parks]) => (
|
{Array.from(grouped.entries()).map(([region, parks]) => (
|
||||||
@@ -19,25 +24,17 @@ export function MobileCardList({ grouped, weekDates, data, today }: MobileCardLi
|
|||||||
<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,18 +1,25 @@
|
|||||||
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, Sun–Sat
|
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 = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
const DOW = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
|
|
||||||
export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
|
export function ParkCard({ park, weekDates, parkData, today, openRideCount, coastersOnly, isOpen, isClosing, isWeatherDelay }: ParkCardProps) {
|
||||||
const openDays = weekDates.filter((d) => parkData[d]?.isOpen && parkData[d]?.hoursLabel);
|
const openDays = weekDates.filter((d) => parkData[d]?.isOpen && parkData[d]?.hoursLabel);
|
||||||
|
const tzAbbr = getTimezoneAbbr(park.timezone);
|
||||||
const isOpenToday = openDays.includes(today);
|
const isOpenToday = openDays.includes(today);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -21,12 +28,14 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
|
|||||||
data-park={park.name.toLowerCase()}
|
data-park={park.name.toLowerCase()}
|
||||||
style={{ textDecoration: "none", display: "block" }}
|
style={{ textDecoration: "none", display: "block" }}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div className="park-card" style={{
|
||||||
background: "var(--color-surface)",
|
background: "var(--color-surface)",
|
||||||
border: "1px solid var(--color-border)",
|
border: "1px solid var(--color-border)",
|
||||||
|
borderLeft: isOpen
|
||||||
|
? `3px solid ${isWeatherDelay ? "var(--color-weather-border)" : isClosing ? "var(--color-closing-border)" : "var(--color-open-border)"}`
|
||||||
|
: "1px solid var(--color-border)",
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
transition: "border-color 120ms ease",
|
|
||||||
}}>
|
}}>
|
||||||
{/* ── Card header ───────────────────────────────────────────────────── */}
|
{/* ── Card header ───────────────────────────────────────────────────── */}
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -34,9 +43,10 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
|
flexWrap: "wrap",
|
||||||
gap: 12,
|
gap: 12,
|
||||||
}}>
|
}}>
|
||||||
<div>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: "0.95rem",
|
fontSize: "0.95rem",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
@@ -54,20 +64,20 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 5 }}>
|
||||||
{isOpenToday ? (
|
{isOpenToday ? (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: "var(--color-open-bg)",
|
background: isWeatherDelay ? "var(--color-weather-bg)" : isClosing ? "var(--color-closing-bg)" : "var(--color-open-bg)",
|
||||||
border: "1px solid var(--color-open-border)",
|
border: `1px solid ${isWeatherDelay ? "var(--color-weather-border)" : isClosing ? "var(--color-closing-border)" : "var(--color-open-border)"}`,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
padding: "4px 10px",
|
padding: "4px 10px",
|
||||||
fontSize: "0.65rem",
|
fontSize: "0.65rem",
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "var(--color-open-text)",
|
color: isWeatherDelay ? "var(--color-weather-text)" : isClosing ? "var(--color-closing-text)" : "var(--color-open-text)",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
flexShrink: 0,
|
|
||||||
letterSpacing: "0.03em",
|
letterSpacing: "0.03em",
|
||||||
}}>
|
}}>
|
||||||
Open today
|
{isWeatherDelay ? "⛈ Weather Delay" : isClosing ? "Closing" : "Open today"}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -79,11 +89,33 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
|
|||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: "var(--color-text-muted)",
|
color: "var(--color-text-muted)",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
}}>
|
||||||
Closed today
|
Closed today
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isOpenToday && isWeatherDelay && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
color: "var(--color-weather-hours, #bfdbfe)",
|
||||||
|
fontWeight: 500,
|
||||||
|
textAlign: "right",
|
||||||
|
}}>
|
||||||
|
⛈ Weather Delay
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isOpenToday && !isWeatherDelay && openRideCount !== undefined && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
color: isClosing ? "var(--color-closing-hours)" : "var(--color-open-hours)",
|
||||||
|
fontWeight: 500,
|
||||||
|
textAlign: "right",
|
||||||
|
}}>
|
||||||
|
{openRideCount} {coastersOnly
|
||||||
|
? (openRideCount === 1 ? "coaster" : "coasters")
|
||||||
|
: (openRideCount === 1 ? "ride" : "rides")} operating
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Open days list ────────────────────────────────────────────────── */}
|
{/* ── Open days list ────────────────────────────────────────────────── */}
|
||||||
@@ -121,10 +153,10 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
|
|||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
{isPH && (
|
{isPH && (
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: "0.58rem",
|
fontSize: "0.65rem",
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "var(--color-ph-label)",
|
color: "var(--color-ph-label)",
|
||||||
letterSpacing: "0.05em",
|
letterSpacing: "0.04em",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
}}>
|
}}>
|
||||||
Passholder
|
Passholder
|
||||||
@@ -139,7 +171,10 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
|
|||||||
? "var(--color-today-text)"
|
? "var(--color-today-text)"
|
||||||
: "var(--color-open-hours)",
|
: "var(--color-open-hours)",
|
||||||
}}>
|
}}>
|
||||||
{dayData.hoursLabel}
|
{dayData.hoursLabel}{" "}
|
||||||
|
<span style={{ fontSize: "0.68rem", fontWeight: 400, color: "var(--color-text-dim)" }}>
|
||||||
|
{tzAbbr}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -10,13 +10,14 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
scraper:
|
scraper:
|
||||||
image: gitea.thewrightserver.net/josh/sixflagssupercalendar:latest
|
image: gitea.thewrightserver.net/josh/sixflagssupercalendar:scraper
|
||||||
volumes:
|
volumes:
|
||||||
- park_data:/app/data
|
- park_data:/app/data
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- TZ=America/New_York # set your local timezone so "3am" is 3am your time
|
- TZ=America/New_York
|
||||||
command: sh /app/scripts/scrape-schedule.sh
|
- PARK_HOURS_STALENESS_HOURS=72
|
||||||
|
- COASTER_STALENESS_HOURS=720
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
13
lib/db.ts
13
lib/db.ts
@@ -46,12 +46,10 @@ export function upsertDay(
|
|||||||
hoursLabel?: string,
|
hoursLabel?: string,
|
||||||
specialType?: string
|
specialType?: string
|
||||||
) {
|
) {
|
||||||
// Today and past dates: INSERT new rows freely, but NEVER overwrite existing records.
|
// Today and future dates: full upsert — hours can change (e.g. weather delays,
|
||||||
// Once an operating day begins the API drops that date from its response, so a
|
// early closures) and the dateless API endpoint now returns today's live data.
|
||||||
// re-scrape would incorrectly record the day as closed. The DB row written when
|
|
||||||
// the date was still in the future is the permanent truth for that day.
|
|
||||||
//
|
//
|
||||||
// Future dates only: full upsert — hours can change and closures can be added.
|
// Past dates: INSERT-only — never overwrite once the day has passed.
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO park_days (park_id, date, is_open, hours_label, special_type, scraped_at)
|
INSERT INTO park_days (park_id, date, is_open, hours_label, special_type, scraped_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
@@ -60,7 +58,7 @@ export function upsertDay(
|
|||||||
hours_label = excluded.hours_label,
|
hours_label = excluded.hours_label,
|
||||||
special_type = excluded.special_type,
|
special_type = excluded.special_type,
|
||||||
scraped_at = excluded.scraped_at
|
scraped_at = excluded.scraped_at
|
||||||
WHERE park_days.date > date('now')
|
WHERE park_days.date >= date('now')
|
||||||
`).run(parkId, date, isOpen ? 1 : 0, hoursLabel ?? null, specialType ?? null, new Date().toISOString());
|
`).run(parkId, date, isOpen ? 1 : 0, hoursLabel ?? null, specialType ?? null, new Date().toISOString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +165,8 @@ export function getMonthCalendar(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STALE_AFTER_MS = 72 * 60 * 60 * 1000; // 72 hours
|
import { parseStalenessHours } from "./env";
|
||||||
|
const STALE_AFTER_MS = parseStalenessHours(process.env.PARK_HOURS_STALENESS_HOURS, 72) * 60 * 60 * 1000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true when the scraper should skip this park+month.
|
* Returns true when the scraper should skip this park+month.
|
||||||
|
|||||||
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));
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
* See: https://queue-times.com/en-US/pages/api
|
* See: https://queue-times.com/en-US/pages/api
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { isCoasterMatch } from "../coaster-match";
|
||||||
|
|
||||||
const BASE = "https://queue-times.com/parks";
|
const BASE = "https://queue-times.com/parks";
|
||||||
|
|
||||||
const HEADERS = {
|
const HEADERS = {
|
||||||
@@ -21,6 +23,8 @@ export interface LiveRide {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
waitMinutes: number;
|
waitMinutes: number;
|
||||||
lastUpdated: string; // ISO 8601
|
lastUpdated: string; // ISO 8601
|
||||||
|
/** True when the ride name appears in the RCDB coaster list for this park. */
|
||||||
|
isCoaster: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LiveRidesResult {
|
export interface LiveRidesResult {
|
||||||
@@ -56,11 +60,16 @@ interface QTResponse {
|
|||||||
* - The request fails
|
* - The request fails
|
||||||
* - The response contains no rides
|
* - 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.
|
* Pass revalidate (seconds) to control Next.js ISR cache lifetime.
|
||||||
* Defaults to 300s (5 min) to match Queue-Times update frequency.
|
* Defaults to 300s (5 min) to match Queue-Times update frequency.
|
||||||
*/
|
*/
|
||||||
export async function fetchLiveRides(
|
export async function fetchLiveRides(
|
||||||
queueTimesId: number,
|
queueTimesId: number,
|
||||||
|
coasterNames: Set<string> | null = null,
|
||||||
revalidate = 300,
|
revalidate = 300,
|
||||||
): Promise<LiveRidesResult | null> {
|
): Promise<LiveRidesResult | null> {
|
||||||
const url = `${BASE}/${queueTimesId}/queue_times.json`;
|
const url = `${BASE}/${queueTimesId}/queue_times.json`;
|
||||||
@@ -68,6 +77,7 @@ export async function fetchLiveRides(
|
|||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: HEADERS,
|
headers: HEADERS,
|
||||||
next: { revalidate },
|
next: { revalidate },
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
} as RequestInit & { next: { revalidate: number } });
|
} as RequestInit & { next: { revalidate: number } });
|
||||||
|
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
@@ -76,7 +86,6 @@ export async function fetchLiveRides(
|
|||||||
|
|
||||||
const rides: LiveRide[] = [];
|
const rides: LiveRide[] = [];
|
||||||
|
|
||||||
// Rides are nested inside lands
|
|
||||||
for (const land of json.lands ?? []) {
|
for (const land of json.lands ?? []) {
|
||||||
for (const r of land.rides ?? []) {
|
for (const r of land.rides ?? []) {
|
||||||
if (!r.name) continue;
|
if (!r.name) continue;
|
||||||
@@ -85,6 +94,7 @@ export async function fetchLiveRides(
|
|||||||
isOpen: r.is_open,
|
isOpen: r.is_open,
|
||||||
waitMinutes: r.wait_time ?? 0,
|
waitMinutes: r.wait_time ?? 0,
|
||||||
lastUpdated: r.last_updated,
|
lastUpdated: r.last_updated,
|
||||||
|
isCoaster: coasterNames ? isCoasterMatch(r.name, coasterNames) : false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,6 +107,7 @@ export async function fetchLiveRides(
|
|||||||
isOpen: r.is_open,
|
isOpen: r.is_open,
|
||||||
waitMinutes: r.wait_time ?? 0,
|
waitMinutes: r.wait_time ?? 0,
|
||||||
lastUpdated: r.last_updated,
|
lastUpdated: r.last_updated,
|
||||||
|
isCoaster: coasterNames ? isCoasterMatch(r.name, coasterNames) : false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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();
|
||||||
|
|||||||
@@ -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