Compare commits
30 Commits
757c2a8d4f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f87462385c | |||
| 2e9cec0b56 | |||
| 0dc84c7597 | |||
| 301ed797ea | |||
| 52f7efd21a | |||
| e1657f07d7 | |||
| d43d8eba86 | |||
| b401f28fef | |||
| e888261ed9 | |||
| 5d9daee627 | |||
| 6447db3008 | |||
| 44d079efb9 | |||
| 7c88a3e568 | |||
| 4f838d99c1 | |||
| bfe099322f | |||
| aa46cc1b3d | |||
| 8027bfc5cf | |||
| f610883dea | |||
| 6f893b909f | |||
| 3c91d9a453 | |||
| deb8e4169b | |||
| 06b911917d | |||
| db668c0787 | |||
| a53e3ffa9f | |||
| 4922dce8ac | |||
| c5c9f750a3 | |||
| 3815da2d3f | |||
| ccd35c4648 | |||
| 70b56158d4 | |||
| 4652a92c29 |
@@ -5,6 +5,7 @@ node_modules
|
||||
data/*.db
|
||||
data/*.db-shm
|
||||
data/*.db-wal
|
||||
backend/data
|
||||
.env*
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# ── Frontend (web) ──────────────────────────────────────────────────────────
|
||||
# Backend API base URL. Required in production — the frontend throws at
|
||||
# startup if this is unset and NODE_ENV=production.
|
||||
BACKEND_URL=http://localhost:3001
|
||||
|
||||
# Optional: Plausible analytics. Both must be set for the script to render.
|
||||
# NEXT_PUBLIC_PLAUSIBLE_SRC=https://plausible.example.com/script.js
|
||||
# NEXT_PUBLIC_PLAUSIBLE_WEBSITE_ID=your-website-id
|
||||
|
||||
# ── Backend ─────────────────────────────────────────────────────────────────
|
||||
# Port the Hono server listens on (default 3001).
|
||||
PORT=3001
|
||||
|
||||
# IANA timezone used by node-cron schedules and operating-hour windows.
|
||||
TZ=America/New_York
|
||||
|
||||
# How long a park's schedule data is considered fresh before the tiered
|
||||
# scraper re-fetches it (default 72).
|
||||
PARK_HOURS_STALENESS_HOURS=72
|
||||
|
||||
# Per-IP request limit for the public API, per minute (default 60).
|
||||
RATE_LIMIT_PER_MIN=60
|
||||
@@ -6,8 +6,44 @@ on:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Lint, typecheck, test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install frontend deps
|
||||
run: npm ci
|
||||
|
||||
- name: Frontend lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Frontend typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Frontend tests
|
||||
run: npm test
|
||||
|
||||
- name: Install backend deps
|
||||
run: npm ci
|
||||
working-directory: backend
|
||||
|
||||
- name: Backend typecheck
|
||||
run: npm run typecheck
|
||||
working-directory: backend
|
||||
|
||||
- name: Backend tests
|
||||
run: npm test
|
||||
working-directory: backend
|
||||
|
||||
build-push:
|
||||
name: Build & Push
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -25,12 +61,12 @@ jobs:
|
||||
context: .
|
||||
target: web
|
||||
push: true
|
||||
tags: ${{ vars.REGISTRY }}/${{ gitea.repository_owner }}/sixflagssupercalendar:web
|
||||
tags: ${{ vars.REGISTRY }}/${{ gitea.repository_owner }}/thoosiecalendar:web
|
||||
|
||||
- name: Build and push scraper image
|
||||
- name: Build and push backend image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: scraper
|
||||
target: backend
|
||||
push: true
|
||||
tags: ${{ vars.REGISTRY }}/${{ gitea.repository_owner }}/sixflagssupercalendar:scraper
|
||||
tags: ${{ vars.REGISTRY }}/${{ gitea.repository_owner }}/thoosiecalendar:backend
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/backend/node_modules
|
||||
/backend/dist
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
@@ -33,8 +35,12 @@ yarn-error.log*
|
||||
/data/*.db
|
||||
/data/*.db-shm
|
||||
/data/*.db-wal
|
||||
/backend/data/
|
||||
parks.db
|
||||
|
||||
# debug script artifacts
|
||||
/debug/
|
||||
|
||||
# env files
|
||||
.env*
|
||||
!.env.example
|
||||
@@ -42,3 +48,4 @@ parks.db
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.gstack/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# SixFlagsSuperCalendar
|
||||
# Thoosie Calendar
|
||||
|
||||
## What This Is
|
||||
|
||||
|
||||
+37
-34
@@ -1,19 +1,33 @@
|
||||
# Stage 1: Install all dependencies (dev included — scraper needs tsx + playwright)
|
||||
FROM node:22-bookworm-slim AS deps
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
# ── builder: Next.js production build ────────────────────────────────────────
|
||||
FROM node:22-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 2: Build the Next.js app
|
||||
FROM deps AS builder
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ── backend-build: compile backend TypeScript to JS (better-sqlite3 build) ───
|
||||
FROM node:22-bookworm-slim AS backend-build
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY backend/package.json backend/package-lock.json* ./backend/
|
||||
COPY backend/tsconfig.json ./backend/
|
||||
RUN cd backend && npm ci
|
||||
COPY backend/src ./backend/src
|
||||
COPY lib ./lib
|
||||
RUN cd backend && npm run build
|
||||
|
||||
# ── backend-prod-deps: production-only node_modules (omits tsc/tsx) ──────────
|
||||
FROM node:22-bookworm-slim AS backend-prod-deps
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app/backend
|
||||
COPY backend/package.json backend/package-lock.json* ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# ── web ──────────────────────────────────────────────────────────────────────
|
||||
# Minimal Next.js runner. No playwright, no tsx, no scripts.
|
||||
# next build --output standalone bundles its own node_modules (incl. better-sqlite3).
|
||||
# Minimal Next.js standalone runner. No database, no native modules.
|
||||
FROM node:22-bookworm-slim AS web
|
||||
WORKDIR /app
|
||||
|
||||
@@ -27,44 +41,33 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
|
||||
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
# ── scraper ───────────────────────────────────────────────────────────────────
|
||||
# Scraper-only image. No Next.js output. Runs on a nightly schedule via
|
||||
# scripts/scrape-schedule.sh. Staleness windows are configurable via env vars:
|
||||
# PARK_HOURS_STALENESS_HOURS (default: 72)
|
||||
# COASTER_STALENESS_HOURS (default: 720 = 30 days)
|
||||
FROM node:22-bookworm-slim AS scraper
|
||||
# ── backend ──────────────────────────────────────────────────────────────────
|
||||
# Hono API server + node-cron scheduler. Owns the SQLite database exclusively.
|
||||
# Runs compiled JS (no tsx/tsc at runtime).
|
||||
FROM node:22-bookworm-slim AS backend
|
||||
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
|
||||
COPY --from=backend-prod-deps --chown=nextjs:nodejs /app/backend/node_modules ./backend/node_modules
|
||||
COPY --from=backend-build --chown=nextjs:nodejs /app/backend/dist ./backend/dist
|
||||
COPY --chown=nextjs:nodejs backend/package.json ./backend/package.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"]
|
||||
RUN mkdir -p /app/backend/data && chown nextjs:nodejs /app/backend/data
|
||||
VOLUME ["/app/backend/data"]
|
||||
|
||||
USER nextjs
|
||||
CMD ["sh", "/app/scripts/scrape-schedule.sh"]
|
||||
EXPOSE 3001
|
||||
ENV PORT=3001
|
||||
|
||||
WORKDIR /app/backend
|
||||
CMD ["node", "dist/backend/src/index.js"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Thoosie Calendar
|
||||
|
||||
A week-by-week calendar showing operating hours for all Six Flags Entertainment Group theme parks — including the former Cedar Fair parks. Data is scraped from the Six Flags internal API and stored locally in SQLite. Click any park to see its full month calendar and live ride status with current wait times.
|
||||
A week-by-week calendar showing operating hours for all Six Flags Entertainment Group theme parks — including the former Cedar Fair parks. Data is fetched from the Six Flags internal API via a backend service and stored in SQLite. Click any park to see its full month calendar and live ride status with current wait times.
|
||||
|
||||
## Parks
|
||||
|
||||
@@ -14,14 +14,35 @@ A week-by-week calendar showing operating hours for all Six Flags Entertainment
|
||||
| **Texas & South** | Over Texas, Fiesta Texas (TX), Frontier City (OK) |
|
||||
| **West & International** | Magic Mountain (CA), Discovery Kingdom (CA), Knott's Berry Farm (CA), California's Great America (CA), Mexico |
|
||||
|
||||
## Documentation
|
||||
|
||||
Detailed docs live in the [`docs/`](docs/) folder:
|
||||
|
||||
- [**Architecture**](docs/ARCHITECTURE.md) -- system design, data flow, caching layers, database schema, external APIs
|
||||
- [**Operations**](docs/OPERATIONS.md) -- deployment, monitoring, troubleshooting, backup, scheduler management
|
||||
- [**API Reference**](docs/API.md) -- complete backend endpoint documentation with request/response examples
|
||||
- [**Development**](docs/DEVELOPMENT.md) -- local setup, project structure, adding parks, testing, code conventions
|
||||
|
||||
## Architecture
|
||||
|
||||
The app runs as two containers:
|
||||
|
||||
| Container | Port | Purpose |
|
||||
|-----------|------|---------|
|
||||
| **web** | 3000 | Next.js frontend — pure presentation layer, fetches all data from the backend API |
|
||||
| **backend** | 3001 | Hono API server — owns the SQLite database, runs tiered cron scheduling, handles all external API calls |
|
||||
|
||||
The frontend makes no direct database or external API calls. All data flows through the backend.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Next.js 15** — App Router, Server Components, standalone output
|
||||
- **Tailwind CSS v4** — `@theme {}` CSS variables, no config file
|
||||
- **SQLite** via `better-sqlite3` — persisted in `/app/data/parks.db`
|
||||
- **Playwright** — one-time headless browser run to discover each park's internal API ID
|
||||
- **Six Flags CloudFront API** — `https://d18car1k0ff81h.cloudfront.net/operating-hours/park/{id}?date=YYYYMM`
|
||||
- **Queue-Times.com API** — live ride open/closed status and wait times, updated every 5 minutes
|
||||
- **Hono** — lightweight TypeScript API framework for the backend
|
||||
- **SQLite** via `better-sqlite3` — owned exclusively by the backend
|
||||
- **node-cron** — tiered scheduling (hourly → daily) for data freshness
|
||||
- **Six Flags CloudFront API** — park operating hours and ride schedules
|
||||
- **Queue-Times.com API** — live ride open/closed status and wait times
|
||||
|
||||
## Ride Status
|
||||
|
||||
@@ -29,14 +50,46 @@ The park detail page shows ride open/closed status using a two-tier approach:
|
||||
|
||||
1. **Live data (Queue-Times.com)** — when a park is operating, ride status and wait times are fetched from the [Queue-Times.com API](https://queue-times.com/en-US/pages/api) and cached for 5 minutes. All 24 parks are mapped. Displays a **Live** badge with per-ride wait times.
|
||||
|
||||
2. **Schedule fallback (Six Flags API)** — the Six Flags operating-hours API drops the current day from its response once a park opens. When Queue-Times data is unavailable, the app falls back to the nearest upcoming date from the Six Flags schedule API as an approximation.
|
||||
2. **Schedule fallback (Six Flags API)** — when Queue-Times data is unavailable, the app falls back to the nearest upcoming date from the Six Flags schedule API as an approximation.
|
||||
|
||||
### Fast Lane wait times
|
||||
|
||||
A second wait number is fetched from Six Flags' `/wait-times/park/{apiId}` endpoint and joined onto each ride by name. The park page has a **Fast Lane** toggle (persisted in `localStorage.fastLaneMode`) that swaps the displayed wait between regular and Fast Lane. On the today chart, Fast Lane appears as a second line.
|
||||
|
||||
### Per-ride history
|
||||
|
||||
Click any ride name on a park page to open `/park/[id]/ride/[slug]` — a detail page with three tabs:
|
||||
|
||||
- **Today** — 5-minute wait-time samples (regular + Fast Lane) with outage markers
|
||||
- **7 days** — daily average / max wait and uptime percentage
|
||||
- **30 days** — same aggregates over a longer window
|
||||
|
||||
Samples are stored in the `ride_wait_samples` table by a Tier-5 cron job that runs every 5 minutes for parks currently within their operating window. Contiguous "ride closed during park hours" runs are shaded on the today chart with a `#N — Hh Mm` label.
|
||||
|
||||
### 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:
|
||||
When live data is shown, a **Coasters only** toggle filters to roller coasters. Coaster lists are hardcoded in `lib/coaster-data.ts`.
|
||||
|
||||
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.
|
||||
## Data Refresh
|
||||
|
||||
The backend runs a tiered scraping schedule via node-cron:
|
||||
|
||||
| Tier | Schedule | Scope |
|
||||
|------|----------|-------|
|
||||
| 1 | Hourly (Mar–Dec) | Today's hours for all parks |
|
||||
| 2 | Every 6 hours | Current month for all parks |
|
||||
| 3 | Twice daily (3 AM, 3 PM) | Current + next month |
|
||||
| 4 | Daily at 3 AM | Full year (respects 72h staleness window) |
|
||||
| 5 | Every 5 minutes | Wait-time samples for all currently-open parks (writes `ride_wait_samples`) |
|
||||
|
||||
Past dates are never overwritten. The hourly tier compares live data against the database before writing — unchanged data is skipped. Each tier has its own concurrency latch — if a tick is still running when the next would fire, the new tick is skipped and logged rather than stacked.
|
||||
|
||||
A manual trigger is available via the backend API:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/scrape/trigger?scope=today
|
||||
# scope: today | month | upcoming | full | force
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -45,29 +98,29 @@ When live data is shown, a **Coasters only** toggle appears if roller coaster da
|
||||
**Prerequisites:** Node.js 22+, npm
|
||||
|
||||
```bash
|
||||
# Install frontend dependencies
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
|
||||
# Install backend dependencies
|
||||
cd backend && npm install && cd ..
|
||||
```
|
||||
|
||||
### Seed the database
|
||||
|
||||
Run once to discover each park's internal API ID (opens a headless browser per park):
|
||||
### Start the backend
|
||||
|
||||
```bash
|
||||
npm run discover
|
||||
cd backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Scrape operating hours for the full year:
|
||||
The backend starts on port 3001, initializes the database, and begins the cron schedule. On first run it creates an empty database — the schedulers will populate it automatically, or trigger a manual scrape.
|
||||
|
||||
### Start the frontend
|
||||
|
||||
```bash
|
||||
npm run scrape
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Force a full re-scrape (ignores the staleness window):
|
||||
|
||||
```bash
|
||||
npm run scrape:force
|
||||
```
|
||||
Open [http://localhost:3000](http://localhost:3000). Navigate weeks with the `←` / `→` buttons (or arrow keys); your selected week persists across visits via the `tcWeek` cookie. Click any park name to open its detail page.
|
||||
|
||||
### Debug a specific park + date
|
||||
|
||||
@@ -77,78 +130,44 @@ Inspect raw API data and parsed output for any park and date:
|
||||
npm run debug -- --park kingsisland --date 2026-06-15
|
||||
```
|
||||
|
||||
Output is printed to the terminal and saved to `debug/{parkId}_{date}.txt`.
|
||||
|
||||
### Run tests
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
### Run the dev server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000). Navigate weeks with the `←` / `→` buttons, or pass `?week=YYYY-MM-DD` directly. Click any park name to open its detail page.
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
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**
|
||||
The app ships as two Docker images:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Both services start. The scraper runs nightly at 3 AM (container timezone, set via `TZ`).
|
||||
Images are built and pushed automatically by CI on every push to `main`.
|
||||
|
||||
### Environment variables
|
||||
|
||||
See [`.env.example`](.env.example) for the full list and defaults.
|
||||
|
||||
**web:**
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `BACKEND_URL` | _(required in prod)_ | Backend API base URL. Throws at startup if unset when `NODE_ENV=production`. |
|
||||
| `NEXT_PUBLIC_PLAUSIBLE_SRC` | — | Plausible script URL. Analytics only render when both this and the website ID are set. |
|
||||
| `NEXT_PUBLIC_PLAUSIBLE_WEBSITE_ID` | — | Plausible website ID. |
|
||||
|
||||
**backend:**
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `3001` | Port the Hono server listens on. |
|
||||
| `TZ` | `UTC` | Timezone for cron schedules (e.g. `America/New_York`). |
|
||||
| `PARK_HOURS_STALENESS_HOURS` | `72` | Hours before park schedule data is re-fetched. |
|
||||
| `RATE_LIMIT_PER_MIN` | `60` | Per-IP request limit for the public API, per minute. Enforced by `backend/src/middleware/rate-limit.ts`; over-limit requests get a `429` with a `Retry-After` header. |
|
||||
|
||||
### Updating
|
||||
|
||||
@@ -156,34 +175,14 @@ Both services start. The scraper runs nightly at 3 AM (container timezone, set v
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
### Scraper environment variables
|
||||
### Backend API endpoints
|
||||
|
||||
Set these in `docker-compose.yml` under the `scraper` service to override defaults:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TZ` | `UTC` | Timezone for the nightly 3 AM run (e.g. `America/New_York`) |
|
||||
| `PARK_HOURS_STALENESS_HOURS` | `72` | Hours before park schedule data is re-fetched |
|
||||
| `COASTER_STALENESS_HOURS` | `720` | Hours before RCDB coaster lists are re-fetched (720 = 30 days) |
|
||||
|
||||
### Manual scrape
|
||||
|
||||
To trigger a scrape outside the nightly schedule:
|
||||
|
||||
```bash
|
||||
docker compose exec scraper npm run scrape
|
||||
```
|
||||
|
||||
Force re-scrape of all data (ignores staleness):
|
||||
|
||||
```bash
|
||||
docker compose exec scraper npm run scrape:force
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Refresh
|
||||
|
||||
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`.
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `GET /api/calendar/week?start=YYYY-MM-DD` | Week calendar for all parks |
|
||||
| `GET /api/calendar/:parkId/month?month=YYYY-MM` | Month calendar for one park |
|
||||
| `GET /api/parks/:id/rides` | Live rides or schedule fallback |
|
||||
| `GET /api/parks/:id/rides/:slug` | Per-ride detail + today/7d/30d wait-time history |
|
||||
| `GET /api/parks` | Park list with metadata |
|
||||
| `GET /api/status` | Health check, scrape timestamps, DB stats |
|
||||
| `POST /api/scrape/trigger?scope=...` | Manual scrape trigger |
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
const WEEK_COOKIE = "tcWeek";
|
||||
const MAX_AGE = 60 * 60 * 24 * 30; // 30 days
|
||||
|
||||
/**
|
||||
* Persist the selected week start (YYYY-MM-DD) in a server-readable cookie
|
||||
* and revalidate the home page so the new week renders.
|
||||
*/
|
||||
export async function setWeek(weekStart: string): Promise<void> {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(weekStart)) return;
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(WEEK_COOKIE, weekStart, {
|
||||
path: "/",
|
||||
maxAge: MAX_AGE,
|
||||
sameSite: "lax",
|
||||
});
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
/** Clear the saved week — used by the "Today" button to jump back to current. */
|
||||
export async function clearWeek(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(WEEK_COOKIE);
|
||||
revalidatePath("/");
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { PARKS } from "@/lib/parks";
|
||||
import { openDb, getMonthCalendar } from "@/lib/db";
|
||||
import type { Park } from "@/lib/scrapers/types";
|
||||
|
||||
export interface ParksApiResponse {
|
||||
parks: Park[];
|
||||
calendar: Record<string, boolean[]>;
|
||||
month: string;
|
||||
daysInMonth: number;
|
||||
}
|
||||
|
||||
function getDaysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month, 0).getDate();
|
||||
}
|
||||
|
||||
function parseMonthParam(
|
||||
monthParam: string | null
|
||||
): { year: number; month: number } | null {
|
||||
if (!monthParam) return null;
|
||||
const match = monthParam.match(/^(\d{4})-(\d{2})$/);
|
||||
if (!match) return null;
|
||||
const year = parseInt(match[1], 10);
|
||||
const month = parseInt(match[2], 10);
|
||||
if (month < 1 || month > 12) return null;
|
||||
return { year, month };
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const monthParam = request.nextUrl.searchParams.get("month");
|
||||
const parsed = parseMonthParam(monthParam);
|
||||
|
||||
if (!parsed) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid or missing ?month=YYYY-MM parameter" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { year, month } = parsed;
|
||||
const daysInMonth = getDaysInMonth(year, month);
|
||||
|
||||
const db = openDb();
|
||||
const calendar = getMonthCalendar(db, year, month);
|
||||
db.close();
|
||||
|
||||
const response: ParksApiResponse = {
|
||||
parks: PARKS,
|
||||
calendar,
|
||||
month: `${year}-${String(month).padStart(2, "0")}`,
|
||||
daysInMonth,
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function RootError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: 24, background: "var(--color-bg)" }}>
|
||||
<div style={{ maxWidth: 480, textAlign: "center" }}>
|
||||
<h1 style={{ fontSize: "1.4rem", fontWeight: 700, color: "var(--color-text)", marginBottom: 12 }}>
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p style={{ color: "var(--color-text-muted)", lineHeight: 1.6, fontSize: "0.9rem", marginBottom: 20 }}>
|
||||
An unexpected error broke this page. Try again in a moment.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
style={{
|
||||
padding: "8px 18px",
|
||||
background: "var(--color-text)",
|
||||
color: "var(--color-bg)",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -110,6 +110,15 @@
|
||||
background: var(--color-surface-hover) !important;
|
||||
}
|
||||
|
||||
/* ── Ride row link hover (LiveRidePanel) ────────────────────────────────── */
|
||||
.ride-row-link {
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.ride-row-link:hover {
|
||||
background: var(--color-surface-hover) !important;
|
||||
border-color: var(--color-accent) !important;
|
||||
}
|
||||
|
||||
/* ── Park month calendar — responsive row heights ───────────────────────── */
|
||||
/* Mobile: fixed uniform rows so narrow columns don't cause height variance */
|
||||
.park-calendar-grid {
|
||||
|
||||
+10
-5
@@ -2,6 +2,9 @@ import type { Metadata } from "next";
|
||||
import Script from "next/script";
|
||||
import "./globals.css";
|
||||
|
||||
const PLAUSIBLE_SRC = process.env.NEXT_PUBLIC_PLAUSIBLE_SRC;
|
||||
const PLAUSIBLE_WEBSITE_ID = process.env.NEXT_PUBLIC_PLAUSIBLE_WEBSITE_ID;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Thoosie Calendar",
|
||||
description: "Theme park operating hours and live ride status at a glance",
|
||||
@@ -12,11 +15,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<html lang="en">
|
||||
<body>
|
||||
{children}
|
||||
<Script
|
||||
src="https://tracking.thewrightserver.net/script.js"
|
||||
data-website-id="a0d0582a-9bd0-4c0d-8e3c-3e6fcc99ec9a"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
{PLAUSIBLE_SRC && PLAUSIBLE_WEBSITE_ID ? (
|
||||
<Script
|
||||
src={PLAUSIBLE_SRC}
|
||||
data-website-id={PLAUSIBLE_WEBSITE_ID}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
) : null}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: 24, background: "var(--color-bg)" }}>
|
||||
<div style={{ maxWidth: 480, textAlign: "center" }}>
|
||||
<div style={{ fontSize: "2.5rem", fontWeight: 700, color: "var(--color-text)", marginBottom: 8 }}>404</div>
|
||||
<h1 style={{ fontSize: "1.1rem", fontWeight: 600, color: "var(--color-text)", marginBottom: 12 }}>
|
||||
Page not found
|
||||
</h1>
|
||||
<p style={{ color: "var(--color-text-muted)", lineHeight: 1.6, fontSize: "0.9rem", marginBottom: 20 }}>
|
||||
We couldn't find what you were looking for.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
padding: "8px 18px",
|
||||
background: "var(--color-text)",
|
||||
color: "var(--color-bg)",
|
||||
borderRadius: 6,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
Back to the calendar
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+31
-131
@@ -1,150 +1,50 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { HomePageClient } from "@/components/HomePageClient";
|
||||
import { PARKS } from "@/lib/parks";
|
||||
import { openDb, getDateRange, getApiId } from "@/lib/db";
|
||||
import { getTodayLocal, isWithinOperatingWindow, getOperatingStatus } from "@/lib/env";
|
||||
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
||||
import { fetchToday } from "@/lib/scrapers/sixflags";
|
||||
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
|
||||
import { readParkMeta, getCoasterSet } from "@/lib/park-meta";
|
||||
import type { DayData } from "@/lib/db";
|
||||
import { getTodayLocal, formatDateLocal } from "@/lib/env";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ week?: string }>;
|
||||
}
|
||||
type WeekData = ComponentProps<typeof HomePageClient>;
|
||||
|
||||
function getWeekStart(param: string | undefined): string {
|
||||
if (param && /^\d{4}-\d{2}-\d{2}$/.test(param)) {
|
||||
const d = new Date(param + "T00:00:00");
|
||||
const WEEK_COOKIE = "tcWeek";
|
||||
|
||||
function getWeekStart(saved: string | undefined): string {
|
||||
if (saved && /^\d{4}-\d{2}-\d{2}$/.test(saved)) {
|
||||
const d = new Date(saved + "T00:00:00");
|
||||
if (!isNaN(d.getTime())) {
|
||||
d.setDate(d.getDate() - d.getDay());
|
||||
return d.toISOString().slice(0, 10);
|
||||
return formatDateLocal(d);
|
||||
}
|
||||
}
|
||||
const todayIso = getTodayLocal();
|
||||
const d = new Date(todayIso + "T00:00:00");
|
||||
d.setDate(d.getDate() - d.getDay());
|
||||
return d.toISOString().slice(0, 10);
|
||||
return formatDateLocal(d);
|
||||
}
|
||||
|
||||
function getWeekDates(sundayIso: string): string[] {
|
||||
return Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new Date(sundayIso + "T00:00:00");
|
||||
d.setDate(d.getDate() + i);
|
||||
return d.toISOString().slice(0, 10);
|
||||
});
|
||||
}
|
||||
export default async function HomePage() {
|
||||
const saved = (await cookies()).get(WEEK_COOKIE)?.value;
|
||||
const weekStart = getWeekStart(saved);
|
||||
|
||||
function getCurrentWeekStart(): string {
|
||||
const todayIso = getTodayLocal();
|
||||
const d = new Date(todayIso + "T00:00:00");
|
||||
d.setDate(d.getDate() - d.getDay());
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default async function HomePage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const weekStart = getWeekStart(params.week);
|
||||
const weekDates = getWeekDates(weekStart);
|
||||
const endDate = weekDates[6];
|
||||
const today = getTodayLocal();
|
||||
const isCurrentWeek = weekStart === getCurrentWeekStart();
|
||||
|
||||
const db = openDb();
|
||||
const data = getDateRange(db, weekStart, endDate);
|
||||
|
||||
// Merge live today data from the Six Flags API (dateless endpoint, 5-min ISR cache).
|
||||
// This ensures weather delays, early closures, and hour changes surface within 5 minutes
|
||||
// without waiting for the next scheduled scrape. Only fetched when viewing the current week.
|
||||
if (weekDates.includes(today)) {
|
||||
const todayResults = await Promise.all(
|
||||
PARKS.map(async (p) => {
|
||||
const apiId = getApiId(db, p.id);
|
||||
if (!apiId) return null;
|
||||
const live = await fetchToday(apiId, 300); // 5-min ISR cache
|
||||
return live ? { parkId: p.id, live } : null;
|
||||
})
|
||||
);
|
||||
for (const result of todayResults) {
|
||||
if (!result) continue;
|
||||
const { parkId, live } = result;
|
||||
if (!data[parkId]) data[parkId] = {};
|
||||
data[parkId][today] = {
|
||||
isOpen: live.isOpen,
|
||||
hoursLabel: live.hoursLabel ?? null,
|
||||
specialType: live.specialType ?? null,
|
||||
} satisfies DayData;
|
||||
}
|
||||
}
|
||||
|
||||
db.close();
|
||||
|
||||
const scrapedCount = Object.values(data).reduce(
|
||||
(sum, parkData) => sum + Object.keys(parkData).length,
|
||||
0
|
||||
const data = await apiFetch<WeekData>(
|
||||
`/api/calendar/week?start=${weekStart}`,
|
||||
{ revalidate: 120 },
|
||||
);
|
||||
|
||||
// Always fetch both ride and coaster counts — the client decides which to display.
|
||||
const parkMeta = readParkMeta();
|
||||
const hasCoasterData = PARKS.some((p) => (parkMeta[p.id]?.coasters.length ?? 0) > 0);
|
||||
|
||||
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])
|
||||
if (!data) {
|
||||
return (
|
||||
<main style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: 24, background: "var(--color-bg)" }}>
|
||||
<div style={{ maxWidth: 480, textAlign: "center" }}>
|
||||
<h1 style={{ fontSize: "1.2rem", fontWeight: 700, color: "var(--color-text)", marginBottom: 12 }}>
|
||||
Calendar data is unavailable
|
||||
</h1>
|
||||
<p style={{ color: "var(--color-text-muted)", lineHeight: 1.6, fontSize: "0.9rem" }}>
|
||||
We could not reach the backend. Refresh in a moment.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HomePageClient
|
||||
weekStart={weekStart}
|
||||
weekDates={weekDates}
|
||||
today={today}
|
||||
isCurrentWeek={isCurrentWeek}
|
||||
data={data}
|
||||
rideCounts={rideCounts}
|
||||
coasterCounts={coasterCounts}
|
||||
openParkIds={openParkIds}
|
||||
closingParkIds={closingParkIds}
|
||||
weatherDelayParkIds={weatherDelayParkIds}
|
||||
hasCoasterData={hasCoasterData}
|
||||
scrapedCount={scrapedCount}
|
||||
/>
|
||||
);
|
||||
return <HomePageClient {...data} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ParkError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: 24, background: "var(--color-bg)" }}>
|
||||
<div style={{ maxWidth: 480, textAlign: "center" }}>
|
||||
<h1 style={{ fontSize: "1.2rem", fontWeight: 700, color: "var(--color-text)", marginBottom: 12 }}>
|
||||
Could not load this park
|
||||
</h1>
|
||||
<p style={{ color: "var(--color-text-muted)", lineHeight: 1.6, fontSize: "0.9rem", marginBottom: 20 }}>
|
||||
Try again in a moment, or head back to the calendar.
|
||||
</p>
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
style={{
|
||||
padding: "8px 18px",
|
||||
background: "var(--color-text)",
|
||||
color: "var(--color-bg)",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
padding: "8px 18px",
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 6,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
Back to calendar
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export default function ParkLoading() {
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
||||
<header style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 20,
|
||||
background: "var(--color-bg)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
padding: "12px 24px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
}}>
|
||||
<div className="skeleton" style={{ width: 120, height: 18, borderRadius: 4 }} />
|
||||
<div style={{ width: 1, height: 16, background: "var(--color-border)" }} />
|
||||
<div className="skeleton" style={{ width: 180, height: 16, borderRadius: 4 }} />
|
||||
</header>
|
||||
|
||||
<main style={{ padding: "24px 32px", maxWidth: 1280, margin: "0 auto", display: "flex", flexDirection: "column", gap: 40 }}>
|
||||
<section>
|
||||
<div className="skeleton" style={{ width: "100%", height: 320, borderRadius: 8 }} />
|
||||
</section>
|
||||
<section>
|
||||
<div className="skeleton" style={{ width: 180, height: 16, borderRadius: 4, marginBottom: 16 }} />
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 6 }}>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="skeleton" style={{ height: 36, borderRadius: 8 }} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+100
-97
@@ -1,33 +1,59 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { BackToCalendarLink } from "@/components/BackToCalendarLink";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PARK_MAP } from "@/lib/parks";
|
||||
import { openDb, getParkMonthData, getApiId } from "@/lib/db";
|
||||
import { scrapeRidesForDay } from "@/lib/scrapers/sixflags";
|
||||
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
||||
import { fetchToday } from "@/lib/scrapers/sixflags";
|
||||
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
|
||||
import { readParkMeta, getCoasterSet } from "@/lib/park-meta";
|
||||
import { ParkMonthCalendar } from "@/components/ParkMonthCalendar";
|
||||
import { LiveRidePanel } from "@/components/LiveRidePanel";
|
||||
import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags";
|
||||
import type { LiveRidesResult } from "@/lib/scrapers/queuetimes"; // used as prop type below
|
||||
import { getTodayLocal, isWithinOperatingWindow } from "@/lib/env";
|
||||
import type { LiveRidesResult } from "@/lib/scrapers/queuetimes";
|
||||
import { getTodayLocal } from "@/lib/env";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { DayData } from "@/lib/types";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ month?: string }>;
|
||||
}
|
||||
|
||||
function parseMonthParam(param: string | undefined): { year: number; month: number } {
|
||||
interface CalendarMonthResponse {
|
||||
parkId: string;
|
||||
year: number;
|
||||
month: number;
|
||||
monthData: Record<string, DayData>;
|
||||
today: string;
|
||||
}
|
||||
|
||||
interface RidesResponse {
|
||||
parkId: string;
|
||||
today: string;
|
||||
parkOpenToday: boolean;
|
||||
withinWindow: boolean;
|
||||
isWeatherDelay: boolean;
|
||||
liveRides: LiveRidesResult | null;
|
||||
scheduleFallback: RidesFetchResult | null;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const park = PARK_MAP.get(id);
|
||||
if (!park) return { title: "Park not found | Thoosie Calendar" };
|
||||
const title = `${park.name} | Thoosie Calendar`;
|
||||
const description = `Operating hours and live ride status for ${park.name} (${park.location.city}, ${park.location.state}).`;
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: { title, description },
|
||||
};
|
||||
}
|
||||
|
||||
function parseMonthParam(param: string | undefined): string {
|
||||
if (param && /^\d{4}-\d{2}$/.test(param)) {
|
||||
const [y, m] = param.split("-").map(Number);
|
||||
if (y >= 2020 && y <= 2030 && m >= 1 && m <= 12) {
|
||||
return { year: y, month: m };
|
||||
return param;
|
||||
}
|
||||
}
|
||||
const [y, m] = getTodayLocal().split("-").map(Number);
|
||||
return { year: y, month: m };
|
||||
return getTodayLocal().slice(0, 7);
|
||||
}
|
||||
|
||||
export default async function ParkPage({ params, searchParams }: PageProps) {
|
||||
@@ -37,60 +63,29 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
||||
const park = PARK_MAP.get(id);
|
||||
if (!park) notFound();
|
||||
|
||||
const today = getTodayLocal();
|
||||
const { year, month } = parseMonthParam(monthParam);
|
||||
const monthStr = parseMonthParam(monthParam);
|
||||
const [year, month] = monthStr.split("-").map(Number);
|
||||
|
||||
const db = openDb();
|
||||
const monthData = getParkMonthData(db, id, year, month);
|
||||
const apiId = getApiId(db, id);
|
||||
db.close();
|
||||
const [calendarData, ridesData] = await Promise.all([
|
||||
apiFetch<CalendarMonthResponse>(
|
||||
`/api/calendar/${id}/month?month=${monthStr}`,
|
||||
{ revalidate: 300 },
|
||||
),
|
||||
apiFetch<RidesResponse>(
|
||||
`/api/parks/${id}/rides`,
|
||||
{ noStore: true },
|
||||
),
|
||||
]);
|
||||
|
||||
// Prefer live today data from the Six Flags API (5-min ISR cache) so that
|
||||
// weather delays and hour changes surface immediately rather than showing
|
||||
// stale DB values. Fall back to DB if the API call fails.
|
||||
const liveToday = apiId !== null ? await fetchToday(apiId, 300).catch(() => null) : null;
|
||||
const todayData = liveToday
|
||||
? { isOpen: liveToday.isOpen, hoursLabel: liveToday.hoursLabel ?? null, specialType: liveToday.specialType ?? null }
|
||||
: monthData[today];
|
||||
const parkOpenToday = todayData?.isOpen && todayData?.hoursLabel;
|
||||
|
||||
// ── Ride data: try live Queue-Times first, fall back to schedule ──────────
|
||||
const queueTimesId = QUEUE_TIMES_IDS[id];
|
||||
const parkMeta = readParkMeta();
|
||||
const coasterSet = getCoasterSet(id, parkMeta);
|
||||
|
||||
let liveRides: LiveRidesResult | null = null;
|
||||
let ridesResult: RidesFetchResult | null = null;
|
||||
|
||||
// Determine if we're within the 1h-before-open to 1h-after-close window.
|
||||
const withinWindow = todayData?.hoursLabel
|
||||
? isWithinOperatingWindow(todayData.hoursLabel, park.timezone)
|
||||
: false;
|
||||
|
||||
if (queueTimesId) {
|
||||
const raw = await fetchLiveRides(queueTimesId, coasterSet);
|
||||
if (raw) {
|
||||
// Outside the window: show the ride list but force all rides closed
|
||||
liveRides = withinWindow
|
||||
? raw
|
||||
: {
|
||||
...raw,
|
||||
rides: raw.rides.map((r) => ({ ...r, isOpen: false, waitMinutes: 0 })),
|
||||
};
|
||||
}
|
||||
if (!calendarData) {
|
||||
return <DataUnavailable parkName={park.name} />;
|
||||
}
|
||||
|
||||
// Weather delay: park is within operating hours but queue-times shows 0 open rides
|
||||
const isWeatherDelay =
|
||||
withinWindow &&
|
||||
liveRides !== null &&
|
||||
liveRides.rides.length > 0 &&
|
||||
liveRides.rides.every((r) => !r.isOpen);
|
||||
|
||||
// Only hit the schedule API as a fallback when Queue-Times live data is unavailable.
|
||||
if (!liveRides && apiId !== null) {
|
||||
ridesResult = await scrapeRidesForDay(apiId, today);
|
||||
}
|
||||
const { monthData, today } = calendarData;
|
||||
const parkOpenToday = ridesData?.parkOpenToday ?? false;
|
||||
const isWeatherDelay = ridesData?.isWeatherDelay ?? false;
|
||||
const liveRides = ridesData?.liveRides ?? null;
|
||||
const ridesResult = ridesData?.scheduleFallback ?? null;
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
||||
@@ -133,23 +128,30 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
||||
{/* ── Ride Status ─────────────────────────────────────────────────── */}
|
||||
<section>
|
||||
<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>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
<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>
|
||||
{liveRides.rides.some((r) => r.hasFastLane) && (
|
||||
<span style={{ fontSize: "0.68rem", color: "var(--color-text-dim)" }}>
|
||||
· Fast Lane via sixflags.com
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : undefined}>
|
||||
Rides
|
||||
{liveRides ? (
|
||||
@@ -167,15 +169,15 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
||||
|
||||
{liveRides ? (
|
||||
<LiveRidePanel
|
||||
parkId={id}
|
||||
liveRides={liveRides}
|
||||
parkOpenToday={!!parkOpenToday}
|
||||
parkOpenToday={parkOpenToday}
|
||||
isWeatherDelay={isWeatherDelay}
|
||||
/>
|
||||
) : (
|
||||
<RideList
|
||||
ridesResult={ridesResult}
|
||||
parkOpenToday={!!parkOpenToday}
|
||||
apiIdMissing={apiId === null && !queueTimesId}
|
||||
parkOpenToday={parkOpenToday}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
@@ -256,24 +258,10 @@ function LiveBadge() {
|
||||
function RideList({
|
||||
ridesResult,
|
||||
parkOpenToday,
|
||||
apiIdMissing,
|
||||
}: {
|
||||
ridesResult: RidesFetchResult | null;
|
||||
parkOpenToday: boolean;
|
||||
apiIdMissing: boolean;
|
||||
}) {
|
||||
if (apiIdMissing) {
|
||||
return (
|
||||
<Callout>
|
||||
Park API ID not discovered yet. Run{" "}
|
||||
<code style={{ background: "var(--color-surface-2)", padding: "1px 5px", borderRadius: 3, fontSize: "0.8em" }}>
|
||||
npm run discover
|
||||
</code>{" "}
|
||||
to enable ride data.
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!parkOpenToday) {
|
||||
return <Callout>Park is closed today — no ride schedule available.</Callout>;
|
||||
}
|
||||
@@ -398,3 +386,18 @@ function Callout({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataUnavailable({ parkName }: { parkName: string }) {
|
||||
return (
|
||||
<main style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: 24, background: "var(--color-bg)" }}>
|
||||
<div style={{ maxWidth: 480, textAlign: "center" }}>
|
||||
<h1 style={{ fontSize: "1.2rem", fontWeight: 700, color: "var(--color-text)", marginBottom: 12 }}>
|
||||
{parkName} data is unavailable
|
||||
</h1>
|
||||
<p style={{ color: "var(--color-text-muted)", lineHeight: 1.6, fontSize: "0.9rem" }}>
|
||||
We could not reach the backend. Refresh in a moment.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function RideError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: 24, background: "var(--color-bg)" }}>
|
||||
<div style={{ maxWidth: 480, textAlign: "center" }}>
|
||||
<h1 style={{ fontSize: "1.2rem", fontWeight: 700, color: "var(--color-text)", marginBottom: 12 }}>
|
||||
Could not load ride history
|
||||
</h1>
|
||||
<p style={{ color: "var(--color-text-muted)", lineHeight: 1.6, fontSize: "0.9rem", marginBottom: 20 }}>
|
||||
The backend is unreachable. Try again in a moment.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
style={{
|
||||
padding: "8px 18px",
|
||||
background: "var(--color-text)",
|
||||
color: "var(--color-bg)",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export default function RideLoading() {
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)", padding: "32px 24px" }}>
|
||||
<div style={{ maxWidth: 960, margin: "0 auto" }}>
|
||||
<div className="skeleton" style={{ width: 160, height: 14, borderRadius: 4, marginBottom: 16 }} />
|
||||
<div className="skeleton" style={{ width: 320, height: 28, borderRadius: 6, marginBottom: 24 }} />
|
||||
<div className="skeleton" style={{ width: 100, height: 24, borderRadius: 999, marginBottom: 24 }} />
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="skeleton" style={{ width: 80, height: 32, borderRadius: 6 }} />
|
||||
))}
|
||||
</div>
|
||||
<div className="skeleton" style={{ width: "100%", height: 280, borderRadius: 8 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PARK_MAP } from "@/lib/parks";
|
||||
import UptimePill from "@/components/charts/UptimePill";
|
||||
import WaitTimeTodayChart from "@/components/charts/WaitTimeTodayChart";
|
||||
import WeeklyStatsChart from "@/components/charts/WeeklyStatsChart";
|
||||
import { getBackendUrl } from "@/lib/api";
|
||||
|
||||
type Tab = "today" | "7d" | "30d";
|
||||
|
||||
interface TodaySample {
|
||||
recordedAt: string;
|
||||
localTime: string;
|
||||
isOpen: boolean;
|
||||
waitMinutes: number | null;
|
||||
fastLaneMinutes: number | null;
|
||||
}
|
||||
|
||||
interface DailyAggregate {
|
||||
localDate: string;
|
||||
avgWait: number | null;
|
||||
maxWait: number | null;
|
||||
avgFastLane: number | null;
|
||||
maxFastLane: number | null;
|
||||
uptimePct: number;
|
||||
sampleCount: number;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
park: { id: string; name: string; shortName: string; timezone: string };
|
||||
ride: {
|
||||
qtRideId: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
isCoaster: boolean;
|
||||
hasFastLane: boolean;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
};
|
||||
live: {
|
||||
isOpen: boolean;
|
||||
waitMinutes: number;
|
||||
hasFastLane: boolean;
|
||||
fastLaneMinutes: number | null;
|
||||
lastUpdated: string;
|
||||
} | null;
|
||||
todayLocal: string;
|
||||
today: TodaySample[];
|
||||
last7d: DailyAggregate[];
|
||||
last30d: DailyAggregate[];
|
||||
coverage: { daysWith7d: number; daysWith30d: number; todaySampleCount: number };
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string; slug: string }>;
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
}
|
||||
|
||||
function parseTab(raw: string | undefined): Tab {
|
||||
if (raw === "7d" || raw === "30d") return raw;
|
||||
return "today";
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { id, slug } = await params;
|
||||
const park = PARK_MAP.get(id);
|
||||
if (!park) return { title: "Ride not found | Thoosie Calendar" };
|
||||
const rideName = decodeURIComponent(slug).replace(/-/g, " ");
|
||||
const title = `${rideName} — ${park.shortName} | Thoosie Calendar`;
|
||||
const description = `Live wait time and uptime history for ${rideName} at ${park.name}.`;
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: { title, description },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RideDetailPage({ params, searchParams }: PageProps) {
|
||||
const { id, slug } = await params;
|
||||
const { tab: tabParam } = await searchParams;
|
||||
|
||||
const park = PARK_MAP.get(id);
|
||||
if (!park) notFound();
|
||||
|
||||
const tab = parseTab(tabParam);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${getBackendUrl()}/api/parks/${id}/rides/${slug}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
return <ErrorState parkId={id} parkName={park.name} />;
|
||||
}
|
||||
|
||||
if (res.status === 404) {
|
||||
return <NoHistoryYet parkId={id} parkName={park.name} slug={slug} />;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return <ErrorState parkId={id} parkName={park.name} />;
|
||||
}
|
||||
|
||||
let data: ApiResponse;
|
||||
try {
|
||||
data = (await res.json()) as ApiResponse;
|
||||
} catch {
|
||||
return <ErrorState parkId={id} parkName={park.name} />;
|
||||
}
|
||||
const { ride, live, today, last7d, last30d, coverage } = data;
|
||||
|
||||
const last30dUptime = last30d.length
|
||||
? last30d.reduce((s, d) => s + d.uptimePct * d.sampleCount, 0) /
|
||||
Math.max(1, last30d.reduce((s, d) => s + d.sampleCount, 0))
|
||||
: 0;
|
||||
const totalSamples30d = last30d.reduce((s, d) => s + d.sampleCount, 0);
|
||||
|
||||
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)",
|
||||
padding: "12px 24px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
}}>
|
||||
<Link href={`/park/${id}`} style={{
|
||||
fontSize: "0.78rem",
|
||||
color: "var(--color-text-secondary)",
|
||||
textDecoration: "none",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}>
|
||||
← {park.shortName}
|
||||
</Link>
|
||||
<div style={{ width: 1, height: 16, background: "var(--color-border)" }} />
|
||||
<span style={{ fontSize: "0.9rem", fontWeight: 600, color: "var(--color-text)", letterSpacing: "-0.01em" }}>
|
||||
{ride.name}
|
||||
</span>
|
||||
{ride.isCoaster && (
|
||||
<span style={{ fontSize: "0.7rem", color: "var(--color-text-muted)" }}>🎢 Coaster</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main style={{ padding: "24px 32px", maxWidth: 1024, margin: "0 auto", display: "flex", flexDirection: "column", gap: 28 }}>
|
||||
|
||||
{/* ── Current state + uptime row ────────────────────────────────────── */}
|
||||
<section style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||
<CurrentStatePill live={live} hasFastLane={ride.hasFastLane} />
|
||||
{last30d.length > 0 && (
|
||||
<UptimePill uptime={last30dUptime} sampleCount={totalSamples30d} label="Uptime · last 30 days" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Range tabs ────────────────────────────────────────────────────── */}
|
||||
<section>
|
||||
<RangeTabs parkId={id} slug={slug} active={tab} coverage={coverage} />
|
||||
</section>
|
||||
|
||||
{/* ── Charts ────────────────────────────────────────────────────────── */}
|
||||
<section>
|
||||
{tab === "today" && (
|
||||
<TodayPanel
|
||||
samples={today}
|
||||
hasFastLane={ride.hasFastLane}
|
||||
sampleCount={coverage.todaySampleCount}
|
||||
parkId={id}
|
||||
firstSeen={ride.firstSeen}
|
||||
/>
|
||||
)}
|
||||
{tab === "7d" && (
|
||||
<RangePanel
|
||||
data={last7d}
|
||||
hasFastLane={ride.hasFastLane}
|
||||
days={coverage.daysWith7d}
|
||||
minDays={3}
|
||||
windowLabel="7 days"
|
||||
firstSeen={ride.firstSeen}
|
||||
/>
|
||||
)}
|
||||
{tab === "30d" && (
|
||||
<RangePanel
|
||||
data={last30d}
|
||||
hasFastLane={ride.hasFastLane}
|
||||
days={coverage.daysWith30d}
|
||||
minDays={10}
|
||||
windowLabel="30 days"
|
||||
firstSeen={ride.firstSeen}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Attribution ───────────────────────────────────────────────────── */}
|
||||
<footer style={{ marginTop: 12, fontSize: "0.68rem", color: "var(--color-text-dim)" }}>
|
||||
Wait times via{" "}
|
||||
<a href="https://queue-times.com" target="_blank" rel="noopener noreferrer" style={{ color: "var(--color-text-muted)" }}>
|
||||
queue-times.com
|
||||
</a>
|
||||
{ride.hasFastLane && <> · Fast Lane via sixflags.com</>}
|
||||
{" · "}Tracking since {formatDate(ride.firstSeen)}
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub-components ─────────────────────────────────────────────────────────
|
||||
|
||||
function CurrentStatePill({
|
||||
live,
|
||||
hasFastLane,
|
||||
}: {
|
||||
live: ApiResponse["live"];
|
||||
hasFastLane: boolean;
|
||||
}) {
|
||||
if (!live) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: "12px 16px",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 8,
|
||||
minWidth: 140,
|
||||
}}>
|
||||
<span style={{ fontSize: "0.65rem", textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--color-text-muted)" }}>
|
||||
Current
|
||||
</span>
|
||||
<div style={{ fontSize: "0.95rem", fontWeight: 600, color: "var(--color-text-muted)", marginTop: 4 }}>
|
||||
Offline
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fg = live.isOpen ? "var(--color-open-text)" : "var(--color-text-muted)";
|
||||
const bg = live.isOpen ? "var(--color-open-bg)" : "var(--color-surface)";
|
||||
const border = live.isOpen ? "var(--color-open-border)" : "var(--color-border)";
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: "12px 16px",
|
||||
background: bg,
|
||||
border: `1px solid ${border}`,
|
||||
borderRadius: 8,
|
||||
minWidth: 140,
|
||||
}}>
|
||||
<span style={{ fontSize: "0.65rem", textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--color-text-muted)" }}>
|
||||
Right now
|
||||
</span>
|
||||
<div style={{ fontSize: "1.5rem", fontWeight: 700, color: fg, lineHeight: 1, marginTop: 4 }}>
|
||||
{!live.isOpen ? "Closed" : live.waitMinutes > 0 ? `${live.waitMinutes} min` : "Walk-on"}
|
||||
</div>
|
||||
{hasFastLane && live.isOpen && live.fastLaneMinutes !== null && (
|
||||
<div style={{ fontSize: "0.72rem", color: "var(--color-accent)", fontWeight: 600, marginTop: 4 }}>
|
||||
⚡ {live.fastLaneMinutes > 0 ? `${live.fastLaneMinutes} min` : "walk-on"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RangeTabs({
|
||||
parkId,
|
||||
slug,
|
||||
active,
|
||||
coverage,
|
||||
}: {
|
||||
parkId: string;
|
||||
slug: string;
|
||||
active: Tab;
|
||||
coverage: ApiResponse["coverage"];
|
||||
}) {
|
||||
const tabs: { id: Tab; label: string; enabled: boolean }[] = [
|
||||
{ id: "today", label: "Today", enabled: true },
|
||||
{ id: "7d", label: "7 days", enabled: coverage.daysWith7d >= 1 },
|
||||
{ id: "30d", label: "30 days", enabled: coverage.daysWith30d >= 1 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 6, borderBottom: "1px solid var(--color-border)", paddingBottom: 0 }}>
|
||||
{tabs.map((t) => {
|
||||
const isActive = t.id === active;
|
||||
const style: React.CSSProperties = {
|
||||
padding: "8px 16px",
|
||||
fontSize: "0.78rem",
|
||||
fontWeight: 600,
|
||||
color: isActive ? "var(--color-text)" : t.enabled ? "var(--color-text-muted)" : "var(--color-text-dim)",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
borderBottom: isActive ? "2px solid var(--color-accent)" : "2px solid transparent",
|
||||
marginBottom: -1,
|
||||
textDecoration: "none",
|
||||
cursor: t.enabled ? "pointer" : "not-allowed",
|
||||
};
|
||||
if (!t.enabled) {
|
||||
return <span key={t.id} style={style} title="Not enough history yet">{t.label}</span>;
|
||||
}
|
||||
return (
|
||||
<Link key={t.id} href={`/park/${parkId}/ride/${slug}?tab=${t.id}`} style={style}>
|
||||
{t.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TodayPanel({
|
||||
samples,
|
||||
hasFastLane,
|
||||
sampleCount,
|
||||
parkId,
|
||||
firstSeen,
|
||||
}: {
|
||||
samples: TodaySample[];
|
||||
hasFastLane: boolean;
|
||||
sampleCount: number;
|
||||
parkId: string;
|
||||
firstSeen: string;
|
||||
}) {
|
||||
if (sampleCount < 12) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Not enough data for today yet"
|
||||
body={`We sample every 5 minutes while ${parkId} is open. The chart appears once we've collected about an hour of data. Tracking started ${formatDate(firstSeen)}.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<ChartHeading>Wait time today</ChartHeading>
|
||||
<WaitTimeTodayChart samples={samples} hasFastLane={hasFastLane} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RangePanel({
|
||||
data,
|
||||
hasFastLane,
|
||||
days,
|
||||
minDays,
|
||||
windowLabel,
|
||||
firstSeen,
|
||||
}: {
|
||||
data: DailyAggregate[];
|
||||
hasFastLane: boolean;
|
||||
days: number;
|
||||
minDays: number;
|
||||
windowLabel: string;
|
||||
firstSeen: string;
|
||||
}) {
|
||||
if (days < minDays) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={`Not enough data for the ${windowLabel} view yet`}
|
||||
body={`Need at least ${minDays} days of tracking; have ${days}. Tracking started ${formatDate(firstSeen)}.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 28 }}>
|
||||
<div>
|
||||
<ChartHeading>Regular wait — avg & max per day</ChartHeading>
|
||||
<WeeklyStatsChart data={data} hasFastLane={hasFastLane} mode="regular" />
|
||||
</div>
|
||||
{hasFastLane && (
|
||||
<div>
|
||||
<ChartHeading>Fast Lane wait — avg & max per day</ChartHeading>
|
||||
<WeeklyStatsChart data={data} hasFastLane={hasFastLane} mode="fastLane" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartHeading({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 style={{
|
||||
fontSize: "0.72rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text)",
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
margin: "0 0 12px",
|
||||
}}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: "20px 24px",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px dashed var(--color-border)",
|
||||
borderRadius: 8,
|
||||
color: "var(--color-text-muted)",
|
||||
fontSize: "0.82rem",
|
||||
lineHeight: 1.55,
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, color: "var(--color-text-secondary)", marginBottom: 4 }}>
|
||||
{title}
|
||||
</div>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoHistoryYet({ parkId, parkName, slug }: { parkId: string; parkName: string; slug: string }) {
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)", padding: "48px 32px" }}>
|
||||
<div style={{ maxWidth: 640, margin: "0 auto" }}>
|
||||
<Link href={`/park/${parkId}`} style={{ fontSize: "0.78rem", color: "var(--color-text-secondary)", textDecoration: "none" }}>
|
||||
← {parkName}
|
||||
</Link>
|
||||
<h1 style={{ fontSize: "1.4rem", fontWeight: 700, color: "var(--color-text)", marginTop: 24, marginBottom: 12 }}>
|
||||
No history yet for {decodeURIComponent(slug).replace(/-/g, " ")}
|
||||
</h1>
|
||||
<p style={{ color: "var(--color-text-muted)", lineHeight: 1.6, fontSize: "0.9rem" }}>
|
||||
We start tracking a ride the first time we see it open in the live feed. Check back after the park is open — once we've recorded an hour of samples, charts will appear here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorState({ parkId, parkName }: { parkId: string; parkName: string }) {
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)", padding: "48px 32px" }}>
|
||||
<div style={{ maxWidth: 640, margin: "0 auto" }}>
|
||||
<Link href={`/park/${parkId}`} style={{ fontSize: "0.78rem", color: "var(--color-text-secondary)", textDecoration: "none" }}>
|
||||
← {parkName}
|
||||
</Link>
|
||||
<h1 style={{ fontSize: "1.4rem", fontWeight: 700, color: "var(--color-text)", marginTop: 24, marginBottom: 12 }}>
|
||||
Could not load ride history
|
||||
</h1>
|
||||
<p style={{ color: "var(--color-text-muted)", lineHeight: 1.6, fontSize: "0.9rem" }}>
|
||||
The backend is unreachable. Try again in a moment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
Generated
+1109
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "thoosie-backend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/backend/src/index.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsx --test tests/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^2.0.0",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"hono": "^4.7.0",
|
||||
"node-cron": "^3.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Dev-only: seed today's `ride_wait_samples` for one ride with a mix of
|
||||
* open + outage data so the WaitTimeTodayChart renders something we can
|
||||
* eyeball. Idempotent for a given ride: wipes then reinserts today's rows.
|
||||
*/
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
|
||||
const PARK_ID = process.argv[2] ?? "greatamerica";
|
||||
const RIDE_SLUG = process.argv[3] ?? "raging-bull";
|
||||
|
||||
const db = new Database(path.join(__dirname, "..", "data", "parks.db"));
|
||||
const ride = db
|
||||
.prepare("SELECT park_id, qt_ride_id, name FROM rides WHERE park_id = ? AND slug = ?")
|
||||
.get(PARK_ID, RIDE_SLUG) as { park_id: string; qt_ride_id: number; name: string } | undefined;
|
||||
|
||||
if (!ride) {
|
||||
console.error(`No ride found for ${PARK_ID}/${RIDE_SLUG} — trigger sampler first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("Seeding for:", ride.name);
|
||||
|
||||
const TZ_OFFSET_HOURS = -5; // Central daylight = UTC-5 (May 30, 2026 is CDT)
|
||||
const today = new Date();
|
||||
const yyyy = today.getUTCFullYear();
|
||||
const mm = String(today.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(today.getUTCDate()).padStart(2, "0");
|
||||
const todayLocal = `${yyyy}-${mm}-${dd}`;
|
||||
|
||||
// Wipe today's rows for this ride.
|
||||
db.prepare(
|
||||
"DELETE FROM ride_wait_samples WHERE park_id = ? AND qt_ride_id = ? AND local_date = ?",
|
||||
).run(ride.park_id, ride.qt_ride_id, todayLocal);
|
||||
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO ride_wait_samples
|
||||
(park_id, qt_ride_id, recorded_at, local_date, local_time, is_open, wait_minutes, fast_lane_minutes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
|
||||
// Build samples at 5-min cadence from 10:00 local through 18:00 local with
|
||||
// two outages: 12:15-12:45 (#1, 30 min) and 15:30-17:00 (#2, 1h 30m).
|
||||
const startLocalMin = 10 * 60;
|
||||
const endLocalMin = 18 * 60;
|
||||
const o1Start = 12 * 60 + 15;
|
||||
const o1End = 12 * 60 + 45;
|
||||
const o2Start = 15 * 60 + 30;
|
||||
const o2End = 17 * 60;
|
||||
|
||||
const dateBase = new Date(`${todayLocal}T00:00:00Z`).getTime();
|
||||
let count = 0;
|
||||
for (let m = startLocalMin; m <= endLocalMin; m += 5) {
|
||||
const isOpen = !((m >= o1Start && m < o1End) || (m >= o2Start && m < o2End));
|
||||
const wait = isOpen ? 15 + Math.floor((m - startLocalMin) / 30) * 5 : null;
|
||||
const fl = isOpen ? (m < 12 * 60 ? 0 : 5) : null; // walk-on morning, 5 min afternoon
|
||||
const localTime = `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
|
||||
const utcMin = m - TZ_OFFSET_HOURS * 60;
|
||||
const recordedAt = new Date(dateBase + utcMin * 60_000).toISOString();
|
||||
insert.run(
|
||||
ride.park_id,
|
||||
ride.qt_ride_id,
|
||||
recordedAt,
|
||||
todayLocal,
|
||||
localTime,
|
||||
isOpen ? 1 : 0,
|
||||
wait,
|
||||
fl,
|
||||
);
|
||||
count++;
|
||||
}
|
||||
console.log(`Inserted ${count} samples for ${todayLocal}`);
|
||||
db.close();
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* One-off cleanup: wipe ride history accumulated before the operating-window
|
||||
* gate was added to the sampler. Safe to re-run; truncates only the two
|
||||
* sample-related tables, leaves park_days untouched.
|
||||
*/
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
|
||||
const dbPath = path.join(__dirname, "..", "data", "parks.db");
|
||||
const db = new Database(dbPath);
|
||||
|
||||
const before = {
|
||||
samples: (db.prepare("SELECT COUNT(*) AS c FROM ride_wait_samples").get() as { c: number }).c,
|
||||
rides: (db.prepare("SELECT COUNT(*) AS c FROM rides").get() as { c: number }).c,
|
||||
};
|
||||
console.log("Before:", before);
|
||||
|
||||
db.exec("DELETE FROM ride_wait_samples; DELETE FROM rides;");
|
||||
|
||||
const after = {
|
||||
samples: (db.prepare("SELECT COUNT(*) AS c FROM ride_wait_samples").get() as { c: number }).c,
|
||||
rides: (db.prepare("SELECT COUNT(*) AS c FROM rides").get() as { c: number }).c,
|
||||
};
|
||||
console.log("After: ", after);
|
||||
|
||||
db.close();
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Centralized env parsing — validate at startup, fail fast on bad config.
|
||||
* Other modules read from the frozen `config` object instead of process.env
|
||||
* so misconfiguration shows up here, not deep in a request handler.
|
||||
*/
|
||||
|
||||
import { parseStalenessHours } from "../../lib/env";
|
||||
|
||||
function parsePort(raw: string | undefined, fallback: number): number {
|
||||
if (!raw) return fallback;
|
||||
const n = parseInt(raw, 10);
|
||||
if (!Number.isFinite(n) || n < 1 || n > 65535) {
|
||||
throw new Error(`Invalid PORT=${raw}: must be an integer in 1..65535`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export const config = Object.freeze({
|
||||
port: parsePort(process.env.PORT, 3001),
|
||||
parkHoursStalenessHours: parseStalenessHours(process.env.PARK_HOURS_STALENESS_HOURS, 72),
|
||||
nodeEnv: process.env.NODE_ENV ?? "development",
|
||||
rateLimitPerMin: parsePort(process.env.RATE_LIMIT_PER_MIN, 60),
|
||||
});
|
||||
|
||||
export type Config = typeof config;
|
||||
@@ -0,0 +1,74 @@
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "data");
|
||||
const DB_PATH = path.join(DATA_DIR, "parks.db");
|
||||
|
||||
let _db: Database.Database | null = null;
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (_db) return _db;
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
_db = new Database(DB_PATH);
|
||||
_db.pragma("journal_mode = WAL");
|
||||
_db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS park_days (
|
||||
park_id TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
is_open INTEGER NOT NULL DEFAULT 0,
|
||||
hours_label TEXT,
|
||||
special_type TEXT,
|
||||
scraped_at TEXT NOT NULL,
|
||||
PRIMARY KEY (park_id, date)
|
||||
)
|
||||
`);
|
||||
try {
|
||||
_db.exec(`ALTER TABLE park_days ADD COLUMN special_type TEXT`);
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
// Per-ride canonical record. PK is (park_id, qt_ride_id) so renames
|
||||
// don't fragment history — the slug just provides pretty URLs.
|
||||
_db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS rides (
|
||||
park_id TEXT NOT NULL,
|
||||
qt_ride_id INTEGER NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
is_coaster INTEGER NOT NULL DEFAULT 0,
|
||||
has_fast_lane INTEGER NOT NULL DEFAULT 0,
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
PRIMARY KEY (park_id, qt_ride_id)
|
||||
)
|
||||
`);
|
||||
_db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_rides_slug ON rides (park_id, slug)`);
|
||||
|
||||
// Time-series wait time samples. recorded_at is UTC; local_date/local_time
|
||||
// are pre-bucketed in the park's IANA timezone at insert time so reads are
|
||||
// pure SQL and DST-safe.
|
||||
_db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ride_wait_samples (
|
||||
park_id TEXT NOT NULL,
|
||||
qt_ride_id INTEGER NOT NULL,
|
||||
recorded_at TEXT NOT NULL,
|
||||
local_date TEXT NOT NULL,
|
||||
local_time TEXT NOT NULL,
|
||||
is_open INTEGER NOT NULL,
|
||||
wait_minutes INTEGER,
|
||||
fast_lane_minutes INTEGER,
|
||||
PRIMARY KEY (park_id, qt_ride_id, recorded_at)
|
||||
)
|
||||
`);
|
||||
|
||||
return _db;
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
if (_db) {
|
||||
_db.close();
|
||||
_db = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import { getDb } from "./index";
|
||||
import { getTodayLocal } from "../../../lib/env";
|
||||
|
||||
import type { DayData } from "../../../lib/types";
|
||||
export type { DayData };
|
||||
|
||||
interface DayRow {
|
||||
park_id: string;
|
||||
date: string;
|
||||
is_open: number;
|
||||
hours_label: string | null;
|
||||
special_type: string | null;
|
||||
}
|
||||
|
||||
function rowToDayData(row: DayRow): DayData {
|
||||
return {
|
||||
isOpen: row.is_open === 1,
|
||||
hoursLabel: row.hours_label,
|
||||
specialType: row.special_type,
|
||||
};
|
||||
}
|
||||
|
||||
export function upsertDay(
|
||||
parkId: string,
|
||||
date: string,
|
||||
isOpen: boolean,
|
||||
hoursLabel?: string,
|
||||
specialType?: string,
|
||||
): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO park_days (park_id, date, is_open, hours_label, special_type, scraped_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (park_id, date) DO UPDATE SET
|
||||
is_open = excluded.is_open,
|
||||
hours_label = excluded.hours_label,
|
||||
special_type = excluded.special_type,
|
||||
scraped_at = excluded.scraped_at
|
||||
WHERE park_days.date >= date('now')`,
|
||||
)
|
||||
.run(parkId, date, isOpen ? 1 : 0, hoursLabel ?? null, specialType ?? null, new Date().toISOString());
|
||||
}
|
||||
|
||||
export function getDateRange(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): Record<string, Record<string, DayData>> {
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT park_id, date, is_open, hours_label, special_type
|
||||
FROM park_days
|
||||
WHERE date >= ? AND date <= ?`,
|
||||
)
|
||||
.all(startDate, endDate) as DayRow[];
|
||||
|
||||
const result: Record<string, Record<string, DayData>> = {};
|
||||
for (const row of rows) {
|
||||
if (!result[row.park_id]) result[row.park_id] = {};
|
||||
result[row.park_id][row.date] = rowToDayData(row);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getParkMonthData(
|
||||
parkId: string,
|
||||
year: number,
|
||||
month: number,
|
||||
): Record<string, DayData> {
|
||||
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT park_id, date, is_open, hours_label, special_type
|
||||
FROM park_days
|
||||
WHERE park_id = ? AND date LIKE ? || '-%'
|
||||
ORDER BY date`,
|
||||
)
|
||||
.all(parkId, prefix) as DayRow[];
|
||||
|
||||
const result: Record<string, DayData> = {};
|
||||
for (const row of rows) {
|
||||
result[row.date] = rowToDayData(row);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getMonthCalendar(
|
||||
year: number,
|
||||
month: number,
|
||||
): Record<string, boolean[]> {
|
||||
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT park_id, date, is_open
|
||||
FROM park_days
|
||||
WHERE date LIKE ? || '-%'
|
||||
ORDER BY date`,
|
||||
)
|
||||
.all(prefix) as { park_id: string; date: string; is_open: number }[];
|
||||
|
||||
const result: Record<string, boolean[]> = {};
|
||||
for (const row of rows) {
|
||||
if (!result[row.park_id]) result[row.park_id] = [];
|
||||
const day = parseInt(row.date.slice(8), 10);
|
||||
result[row.park_id][day - 1] = row.is_open === 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getDayData(parkId: string, date: string): DayData | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`SELECT park_id, date, is_open, hours_label, special_type
|
||||
FROM park_days
|
||||
WHERE park_id = ? AND date = ?`,
|
||||
)
|
||||
.get(parkId, date) as DayRow | undefined;
|
||||
|
||||
return row ? rowToDayData(row) : null;
|
||||
}
|
||||
|
||||
export function isMonthScraped(
|
||||
parkId: string,
|
||||
year: number,
|
||||
month: number,
|
||||
staleAfterMs: number,
|
||||
): boolean {
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
const lastDay = `${year}-${String(month).padStart(2, "0")}-${String(daysInMonth).padStart(2, "0")}`;
|
||||
const today = getTodayLocal();
|
||||
|
||||
if (lastDay < today) return true;
|
||||
|
||||
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`SELECT MAX(scraped_at) AS last_scraped
|
||||
FROM park_days
|
||||
WHERE park_id = ? AND date LIKE ? || '-%'`,
|
||||
)
|
||||
.get(parkId, prefix) as { last_scraped: string | null };
|
||||
|
||||
if (!row.last_scraped) return false;
|
||||
return Date.now() - new Date(row.last_scraped).getTime() < staleAfterMs;
|
||||
}
|
||||
|
||||
export function getLastScrapeTime(): string | null {
|
||||
const row = getDb()
|
||||
.prepare(`SELECT MAX(scraped_at) AS last_scraped FROM park_days`)
|
||||
.get() as { last_scraped: string | null };
|
||||
return row.last_scraped;
|
||||
}
|
||||
|
||||
export function getParkDayCount(): number {
|
||||
const row = getDb()
|
||||
.prepare(`SELECT COUNT(*) AS count FROM park_days`)
|
||||
.get() as { count: number };
|
||||
return row.count;
|
||||
}
|
||||
|
||||
export function transact(fn: () => void): void {
|
||||
getDb().transaction(fn)();
|
||||
}
|
||||
|
||||
// ─── Ride history ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RideRow {
|
||||
parkId: string;
|
||||
qtRideId: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
isCoaster: boolean;
|
||||
hasFastLane: boolean;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
interface RideDbRow {
|
||||
park_id: string;
|
||||
qt_ride_id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
is_coaster: number;
|
||||
has_fast_lane: number;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
function rowToRide(row: RideDbRow): RideRow {
|
||||
return {
|
||||
parkId: row.park_id,
|
||||
qtRideId: row.qt_ride_id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
isCoaster: row.is_coaster === 1,
|
||||
hasFastLane: row.has_fast_lane === 1,
|
||||
firstSeen: row.first_seen,
|
||||
lastSeen: row.last_seen,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a ride if new, otherwise update its mutable fields (name, slug,
|
||||
* has_fast_lane, last_seen). is_coaster is sticky once set true.
|
||||
*/
|
||||
export function upsertRide(
|
||||
parkId: string,
|
||||
qtRideId: number,
|
||||
slug: string,
|
||||
name: string,
|
||||
isCoaster: boolean,
|
||||
hasFastLane: boolean,
|
||||
now: string,
|
||||
): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO rides (park_id, qt_ride_id, slug, name, is_coaster, has_fast_lane, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (park_id, qt_ride_id) DO UPDATE SET
|
||||
slug = excluded.slug,
|
||||
name = excluded.name,
|
||||
is_coaster = MAX(rides.is_coaster, excluded.is_coaster),
|
||||
has_fast_lane = MAX(rides.has_fast_lane, excluded.has_fast_lane),
|
||||
last_seen = excluded.last_seen`,
|
||||
)
|
||||
.run(parkId, qtRideId, slug, name, isCoaster ? 1 : 0, hasFastLane ? 1 : 0, now, now);
|
||||
}
|
||||
|
||||
export function insertSample(
|
||||
parkId: string,
|
||||
qtRideId: number,
|
||||
recordedAt: string,
|
||||
localDate: string,
|
||||
localTime: string,
|
||||
isOpen: boolean,
|
||||
waitMinutes: number | null,
|
||||
fastLaneMinutes: number | null,
|
||||
): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO ride_wait_samples
|
||||
(park_id, qt_ride_id, recorded_at, local_date, local_time, is_open, wait_minutes, fast_lane_minutes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
parkId,
|
||||
qtRideId,
|
||||
recordedAt,
|
||||
localDate,
|
||||
localTime,
|
||||
isOpen ? 1 : 0,
|
||||
waitMinutes,
|
||||
fastLaneMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRideBySlug(parkId: string, slug: string): RideRow | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`SELECT park_id, qt_ride_id, slug, name, is_coaster, has_fast_lane, first_seen, last_seen
|
||||
FROM rides
|
||||
WHERE park_id = ? AND slug = ?`,
|
||||
)
|
||||
.get(parkId, slug) as RideDbRow | undefined;
|
||||
return row ? rowToRide(row) : null;
|
||||
}
|
||||
|
||||
export function listRidesForPark(parkId: string): RideRow[] {
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT park_id, qt_ride_id, slug, name, is_coaster, has_fast_lane, first_seen, last_seen
|
||||
FROM rides
|
||||
WHERE park_id = ?
|
||||
ORDER BY name`,
|
||||
)
|
||||
.all(parkId) as RideDbRow[];
|
||||
return rows.map(rowToRide);
|
||||
}
|
||||
|
||||
export interface DailySample {
|
||||
recordedAt: string;
|
||||
localTime: string;
|
||||
isOpen: boolean;
|
||||
waitMinutes: number | null;
|
||||
fastLaneMinutes: number | null;
|
||||
}
|
||||
|
||||
export function getRideSamplesForDay(
|
||||
parkId: string,
|
||||
qtRideId: number,
|
||||
localDate: string,
|
||||
): DailySample[] {
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT recorded_at, local_time, is_open, wait_minutes, fast_lane_minutes
|
||||
FROM ride_wait_samples
|
||||
WHERE park_id = ? AND qt_ride_id = ? AND local_date = ?
|
||||
ORDER BY recorded_at`,
|
||||
)
|
||||
.all(parkId, qtRideId, localDate) as {
|
||||
recorded_at: string;
|
||||
local_time: string;
|
||||
is_open: number;
|
||||
wait_minutes: number | null;
|
||||
fast_lane_minutes: number | null;
|
||||
}[];
|
||||
return rows.map((r) => ({
|
||||
recordedAt: r.recorded_at,
|
||||
localTime: r.local_time,
|
||||
isOpen: r.is_open === 1,
|
||||
waitMinutes: r.wait_minutes,
|
||||
fastLaneMinutes: r.fast_lane_minutes,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface DailyAggregate {
|
||||
localDate: string;
|
||||
avgWait: number | null;
|
||||
maxWait: number | null;
|
||||
avgFastLane: number | null;
|
||||
maxFastLane: number | null;
|
||||
uptimePct: number;
|
||||
sampleCount: number;
|
||||
}
|
||||
|
||||
export function getRideDailyAggregates(
|
||||
parkId: string,
|
||||
qtRideId: number,
|
||||
sinceLocalDate: string,
|
||||
): DailyAggregate[] {
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT local_date,
|
||||
AVG(CASE WHEN is_open = 1 THEN wait_minutes END) AS avg_wait,
|
||||
MAX(CASE WHEN is_open = 1 THEN wait_minutes END) AS max_wait,
|
||||
AVG(CASE WHEN is_open = 1 THEN fast_lane_minutes END) AS avg_fl,
|
||||
MAX(CASE WHEN is_open = 1 THEN fast_lane_minutes END) AS max_fl,
|
||||
CAST(SUM(is_open) AS REAL) / COUNT(*) AS uptime_pct,
|
||||
COUNT(*) AS sample_count
|
||||
FROM ride_wait_samples
|
||||
WHERE park_id = ? AND qt_ride_id = ? AND local_date >= ?
|
||||
GROUP BY local_date
|
||||
ORDER BY local_date`,
|
||||
)
|
||||
.all(parkId, qtRideId, sinceLocalDate) as {
|
||||
local_date: string;
|
||||
avg_wait: number | null;
|
||||
max_wait: number | null;
|
||||
avg_fl: number | null;
|
||||
max_fl: number | null;
|
||||
uptime_pct: number;
|
||||
sample_count: number;
|
||||
}[];
|
||||
return rows.map((r) => ({
|
||||
localDate: r.local_date,
|
||||
avgWait: r.avg_wait,
|
||||
maxWait: r.max_wait,
|
||||
avgFastLane: r.avg_fl,
|
||||
maxFastLane: r.max_fl,
|
||||
uptimePct: r.uptime_pct,
|
||||
sampleCount: r.sample_count,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of distinct local_date values for a ride in the given window.
|
||||
* Used to decide whether 7d/30d charts have enough data to render.
|
||||
*/
|
||||
export function countRideDays(parkId: string, qtRideId: number, sinceLocalDate: string): number {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`SELECT COUNT(DISTINCT local_date) AS days
|
||||
FROM ride_wait_samples
|
||||
WHERE park_id = ? AND qt_ride_id = ? AND local_date >= ?`,
|
||||
)
|
||||
.get(parkId, qtRideId, sinceLocalDate) as { days: number };
|
||||
return row.days;
|
||||
}
|
||||
|
||||
export function getRideSampleCount(): number {
|
||||
const row = getDb()
|
||||
.prepare(`SELECT COUNT(*) AS count FROM ride_wait_samples`)
|
||||
.get() as { count: number };
|
||||
return row.count;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Hono } from "hono";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { cors } from "hono/cors";
|
||||
|
||||
import { config } from "./config";
|
||||
import { log } from "./log";
|
||||
import { getDb, closeDb } from "./db/index";
|
||||
import { startScheduler } from "./services/scheduler";
|
||||
import { rateLimit } from "./middleware/rate-limit";
|
||||
|
||||
import calendarRoutes from "./routes/calendar";
|
||||
import parksRoutes from "./routes/parks";
|
||||
import ridesRoutes from "./routes/rides";
|
||||
import rideHistoryRoutes from "./routes/ride-history";
|
||||
import statusRoutes from "./routes/status";
|
||||
import scrapeRoutes from "./routes/scrape";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.use("*", async (c, next) => {
|
||||
const start = Date.now();
|
||||
await next();
|
||||
log.info("http", `${c.req.method} ${c.req.path}`, {
|
||||
status: c.res.status,
|
||||
ms: Date.now() - start,
|
||||
});
|
||||
});
|
||||
app.use("*", cors());
|
||||
app.use("*", rateLimit(config.rateLimitPerMin));
|
||||
|
||||
app.route("/api/calendar", calendarRoutes);
|
||||
app.route("/api/parks", parksRoutes);
|
||||
app.route("/api/parks", ridesRoutes);
|
||||
app.route("/api/parks", rideHistoryRoutes);
|
||||
app.route("/api/status", statusRoutes);
|
||||
app.route("/api/scrape", scrapeRoutes);
|
||||
|
||||
log.info("startup", "config loaded", {
|
||||
port: config.port,
|
||||
nodeEnv: config.nodeEnv,
|
||||
parkHoursStalenessHours: config.parkHoursStalenessHours,
|
||||
rateLimitPerMin: config.rateLimitPerMin,
|
||||
});
|
||||
|
||||
getDb();
|
||||
log.info("startup", "database initialized");
|
||||
|
||||
startScheduler();
|
||||
|
||||
const server = serve({ fetch: app.fetch, port: config.port }, (info) => {
|
||||
log.info("startup", "listening", { url: `http://localhost:${info.port}` });
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
function shutdown(signal: string): void {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
log.info("shutdown", "signal received", { signal });
|
||||
|
||||
const forceExit = setTimeout(() => {
|
||||
log.error("shutdown", "force-exiting after 5s grace period");
|
||||
process.exit(1);
|
||||
}, 5000);
|
||||
forceExit.unref();
|
||||
|
||||
server.close((err) => {
|
||||
if (err) log.error("shutdown", "http server close error", { err: err.message });
|
||||
else log.info("shutdown", "http server closed");
|
||||
try {
|
||||
closeDb();
|
||||
log.info("shutdown", "database closed");
|
||||
} catch (err) {
|
||||
log.error("shutdown", "database close error", { err: (err as Error).message });
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
log.error("process", "unhandledRejection", { reason: String(reason) });
|
||||
});
|
||||
process.on("uncaughtException", (err) => {
|
||||
log.error("process", "uncaughtException", { err: err.message, stack: err.stack });
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Tiny structured logger. Emits `[ISO] [LEVEL] [tag] msg key=value...` so logs
|
||||
* are searchable and grep-friendly without dragging in pino/winston.
|
||||
*/
|
||||
|
||||
type Meta = Record<string, unknown>;
|
||||
type Level = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
function formatMeta(meta?: Meta): string {
|
||||
if (!meta) return "";
|
||||
const parts: string[] = [];
|
||||
for (const [k, v] of Object.entries(meta)) {
|
||||
if (v === undefined) continue;
|
||||
const s = typeof v === "string" ? v : JSON.stringify(v);
|
||||
parts.push(`${k}=${s}`);
|
||||
}
|
||||
return parts.length ? " " + parts.join(" ") : "";
|
||||
}
|
||||
|
||||
function emit(level: Level, tag: string, msg: string, meta?: Meta): void {
|
||||
const line = `${new Date().toISOString()} [${level}] [${tag}] ${msg}${formatMeta(meta)}`;
|
||||
if (level === "ERROR") console.error(line);
|
||||
else console.log(line);
|
||||
}
|
||||
|
||||
export const log = {
|
||||
info: (tag: string, msg: string, meta?: Meta) => emit("INFO", tag, msg, meta),
|
||||
warn: (tag: string, msg: string, meta?: Meta) => emit("WARN", tag, msg, meta),
|
||||
error: (tag: string, msg: string, meta?: Meta) => emit("ERROR", tag, msg, meta),
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Simple IP-based rate limiter. Fixed-window counter in a Map, swept on
|
||||
* each request — no external dependency, sufficient for a single-instance
|
||||
* public site. Keys honour x-forwarded-for so a reverse proxy doesn't
|
||||
* collapse every client to one bucket.
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { log } from "../log";
|
||||
|
||||
interface Bucket {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const WINDOW_MS = 60_000;
|
||||
|
||||
function clientIp(c: Parameters<MiddlewareHandler>[0]): string {
|
||||
const fwd = c.req.header("x-forwarded-for");
|
||||
if (fwd) return fwd.split(",")[0].trim();
|
||||
const real = c.req.header("x-real-ip");
|
||||
if (real) return real.trim();
|
||||
// @hono/node-server attaches the raw connection on c.env.incoming
|
||||
const incoming = (c.env as { incoming?: { socket?: { remoteAddress?: string } } } | undefined)?.incoming;
|
||||
return incoming?.socket?.remoteAddress ?? "unknown";
|
||||
}
|
||||
|
||||
export function rateLimit(limitPerMin: number): MiddlewareHandler {
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
return async (c, next) => {
|
||||
const now = Date.now();
|
||||
const ip = clientIp(c);
|
||||
let bucket = buckets.get(ip);
|
||||
|
||||
if (!bucket || bucket.resetAt <= now) {
|
||||
bucket = { count: 0, resetAt: now + WINDOW_MS };
|
||||
buckets.set(ip, bucket);
|
||||
}
|
||||
|
||||
bucket.count++;
|
||||
|
||||
if (bucket.count > limitPerMin) {
|
||||
const retryAfter = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000));
|
||||
log.warn("rate-limit", "blocked", { ip, count: bucket.count, retryAfter });
|
||||
c.header("Retry-After", String(retryAfter));
|
||||
return c.json({ error: "Too many requests" }, 429);
|
||||
}
|
||||
|
||||
// Opportunistic cleanup so the Map doesn't grow unbounded.
|
||||
if (buckets.size > 10_000) {
|
||||
for (const [k, v] of buckets) {
|
||||
if (v.resetAt <= now) buckets.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Hono } from "hono";
|
||||
import { PARKS } from "../../../lib/parks";
|
||||
import { QUEUE_TIMES_IDS } from "../../../lib/queue-times-map";
|
||||
import { getCoasterSet } from "../../../lib/coaster-data";
|
||||
import { getTodayLocal, formatDateLocal, isWithinOperatingWindow, getOperatingStatus } from "../../../lib/env";
|
||||
import { fetchToday } from "../../../lib/scrapers/sixflags";
|
||||
import { fetchLiveRides } from "../../../lib/scrapers/queuetimes";
|
||||
import { getDateRange, getParkMonthData, type DayData } from "../db/queries";
|
||||
import { TtlCache } from "../services/cache";
|
||||
import { log } from "../log";
|
||||
|
||||
type TodayCacheValue = { date: string; isOpen: boolean; hoursLabel?: string; specialType?: string } | null;
|
||||
const todayCache = new TtlCache<TodayCacheValue>(5 * 60 * 1000);
|
||||
// Tracks parks we've already attempted this TTL window so a null cache hit
|
||||
// doesn't re-fetch on every request. Same TTL as todayCache so they expire
|
||||
// together.
|
||||
const todayChecked = new TtlCache<true>(5 * 60 * 1000);
|
||||
// "ok" — fresh fetch succeeded; counts reflect actual live data.
|
||||
// "unknown" — fetch failed (network, timeout, rate-limit, upstream null).
|
||||
// We do NOT know whether the park is in weather delay; treat as
|
||||
// "no signal" so the homepage doesn't falsely flag it. Stored with
|
||||
// a shorter TTL so we recover quickly when upstream comes back.
|
||||
type RidesCacheEntry =
|
||||
| { kind: "ok"; openRides: number; openCoasters: number }
|
||||
| { kind: "unknown" };
|
||||
const ridesCache = new TtlCache<RidesCacheEntry>(5 * 60 * 1000);
|
||||
const UNKNOWN_RIDES_TTL_MS = 30_000;
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/week", async (c) => {
|
||||
const startParam = c.req.query("start");
|
||||
if (!startParam || !/^\d{4}-\d{2}-\d{2}$/.test(startParam)) {
|
||||
return c.json({ error: "Missing or invalid ?start=YYYY-MM-DD" }, 400);
|
||||
}
|
||||
|
||||
const weekDates = Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new Date(startParam + "T00:00:00");
|
||||
d.setDate(d.getDate() + i);
|
||||
return formatDateLocal(d);
|
||||
});
|
||||
const endDate = weekDates[6];
|
||||
const today = getTodayLocal();
|
||||
|
||||
const data = getDateRange(startParam, endDate);
|
||||
|
||||
// Merge live today data
|
||||
if (weekDates.includes(today)) {
|
||||
await Promise.all(
|
||||
PARKS.map(async (p) => {
|
||||
let live = todayCache.get(p.id);
|
||||
if (live === null && todayChecked.get(p.id) === null) {
|
||||
live = await fetchToday(p.apiId).catch((err: Error) => {
|
||||
log.warn("calendar.week", "fetchToday failed", { park: p.id, err: err.message });
|
||||
return null;
|
||||
});
|
||||
todayCache.set(p.id, live);
|
||||
todayChecked.set(p.id, true);
|
||||
}
|
||||
if (!live) return;
|
||||
if (!data[p.id]) data[p.id] = {};
|
||||
data[p.id][today] = {
|
||||
isOpen: live.isOpen,
|
||||
hoursLabel: live.hoursLabel ?? null,
|
||||
specialType: live.specialType ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const currentWeekStart = (() => {
|
||||
const d = new Date(today + "T00:00:00");
|
||||
d.setDate(d.getDate() - d.getDay());
|
||||
return formatDateLocal(d);
|
||||
})();
|
||||
const isCurrentWeek = startParam === currentWeekStart;
|
||||
|
||||
// Live status for today
|
||||
let rideCounts: Record<string, number> = {};
|
||||
let coasterCounts: Record<string, number> = {};
|
||||
let openParkIds: string[] = [];
|
||||
let closingParkIds: string[] = [];
|
||||
let weatherDelayParkIds: string[] = [];
|
||||
|
||||
if (weekDates.includes(today)) {
|
||||
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);
|
||||
|
||||
const trackedParks = openTodayParks.filter((p) => QUEUE_TIMES_IDS[p.id]);
|
||||
const results = await Promise.all(
|
||||
trackedParks.map(async (p): Promise<{ id: string; entry: RidesCacheEntry }> => {
|
||||
let entry = ridesCache.get(p.id);
|
||||
if (!entry) {
|
||||
const coasterSet = getCoasterSet(p.id);
|
||||
const result = await fetchLiveRides(QUEUE_TIMES_IDS[p.id], coasterSet).catch((err: Error) => {
|
||||
log.warn("calendar.week", "fetchLiveRides failed", { park: p.id, err: err.message });
|
||||
return null;
|
||||
});
|
||||
if (result) {
|
||||
entry = {
|
||||
kind: "ok",
|
||||
openRides: result.rides.filter((r) => r.isOpen).length,
|
||||
openCoasters: result.rides.filter((r) => r.isOpen && r.isCoaster).length,
|
||||
};
|
||||
ridesCache.set(p.id, entry);
|
||||
} else {
|
||||
entry = { kind: "unknown" };
|
||||
ridesCache.set(p.id, entry, UNKNOWN_RIDES_TTL_MS);
|
||||
}
|
||||
}
|
||||
return { id: p.id, entry };
|
||||
}),
|
||||
);
|
||||
|
||||
// Only flag weather delay when we know rides are actually closed. An
|
||||
// "unknown" entry means our upstream fetch failed — claim no badge rather
|
||||
// than falsely showing the storm icon for an outage. Parks in the
|
||||
// post-close wind-down buffer ("closing") naturally have rides shutting
|
||||
// off, so suppress the weather badge there too.
|
||||
const closingSet = new Set(closingParkIds);
|
||||
for (const { id, entry } of results) {
|
||||
if (entry.kind !== "ok") continue;
|
||||
if (entry.openRides === 0) {
|
||||
if (!closingSet.has(id)) weatherDelayParkIds.push(id);
|
||||
} else {
|
||||
rideCounts[id] = entry.openRides;
|
||||
}
|
||||
if (entry.openCoasters > 0) {
|
||||
coasterCounts[id] = entry.openCoasters;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scrapedCount = Object.values(data).reduce((sum, parkData) => sum + Object.keys(parkData).length, 0);
|
||||
|
||||
c.header("Cache-Control", "public, max-age=120, stale-while-revalidate=300");
|
||||
return c.json({
|
||||
weekStart: startParam,
|
||||
weekDates,
|
||||
today,
|
||||
isCurrentWeek,
|
||||
data,
|
||||
rideCounts,
|
||||
coasterCounts,
|
||||
openParkIds,
|
||||
closingParkIds,
|
||||
weatherDelayParkIds,
|
||||
hasCoasterData: true,
|
||||
scrapedCount,
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/:parkId/month", async (c) => {
|
||||
const parkId = c.req.param("parkId");
|
||||
const monthParam = c.req.query("month");
|
||||
|
||||
if (!monthParam || !/^\d{4}-\d{2}$/.test(monthParam)) {
|
||||
return c.json({ error: "Missing or invalid ?month=YYYY-MM" }, 400);
|
||||
}
|
||||
|
||||
const [yearStr, monthStr] = monthParam.split("-");
|
||||
const year = parseInt(yearStr);
|
||||
const month = parseInt(monthStr);
|
||||
|
||||
if (month < 1 || month > 12) {
|
||||
return c.json({ error: "Month must be 1-12" }, 400);
|
||||
}
|
||||
|
||||
const monthData = getParkMonthData(parkId, year, month);
|
||||
const today = getTodayLocal();
|
||||
|
||||
// Merge live today if viewing current month
|
||||
const park = PARKS.find((p) => p.id === parkId);
|
||||
if (park) {
|
||||
const liveToday = await fetchToday(park.apiId).catch((err: Error) => {
|
||||
log.warn("calendar.month", "fetchToday failed", { park: park.id, err: err.message });
|
||||
return null;
|
||||
});
|
||||
if (liveToday) {
|
||||
monthData[today] = {
|
||||
isOpen: liveToday.isOpen,
|
||||
hoursLabel: liveToday.hoursLabel ?? null,
|
||||
specialType: liveToday.specialType ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
c.header("Cache-Control", "public, max-age=300, stale-while-revalidate=600");
|
||||
return c.json({ parkId, year, month, monthData, today });
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Hono } from "hono";
|
||||
import { PARKS, PARK_MAP } from "../../../lib/parks";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/", (c) => {
|
||||
c.header("Cache-Control", "public, max-age=3600");
|
||||
return c.json({ parks: PARKS });
|
||||
});
|
||||
|
||||
app.get("/:id", (c) => {
|
||||
const park = PARK_MAP.get(c.req.param("id"));
|
||||
if (!park) return c.json({ error: "Park not found" }, 404);
|
||||
|
||||
c.header("Cache-Control", "public, max-age=3600");
|
||||
return c.json(park);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Ride detail + history endpoint.
|
||||
*
|
||||
* GET /api/parks/:parkId/rides/:slug
|
||||
* Returns ride metadata, today's per-sample series, and 7d + 30d
|
||||
* per-day aggregates in a single round-trip.
|
||||
*
|
||||
* The frontend renders Today / 7d / 30d tabs from one payload — no
|
||||
* client-side fetching of additional ranges. Cache: 60s public.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { PARK_MAP } from "../../../lib/parks";
|
||||
import {
|
||||
getDayData,
|
||||
getRideBySlug,
|
||||
getRideSamplesForDay,
|
||||
getRideDailyAggregates,
|
||||
countRideDays,
|
||||
type DailySample,
|
||||
type DailyAggregate,
|
||||
} from "../db/queries";
|
||||
import { liveRidesCache, fastLaneCache } from "../services/live-cache";
|
||||
import { slugifyRideName } from "../../../lib/ride-slug";
|
||||
import { lookupFastLane } from "../../../lib/scrapers/sixflags-waittimes";
|
||||
import { getTodayLocal, isWithinOperatingWindow } from "../../../lib/env";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
function formatLocalDate(d: Date, tz: string): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD `n` days before the given local date (calendar-day math). */
|
||||
function daysAgoIso(localDate: string, n: number): string {
|
||||
const [y, m, d] = localDate.split("-").map(Number);
|
||||
const date = new Date(Date.UTC(y, m - 1, d));
|
||||
date.setUTCDate(date.getUTCDate() - n);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
app.get("/:parkId/rides/:slug", (c) => {
|
||||
const parkId = c.req.param("parkId");
|
||||
const slug = c.req.param("slug");
|
||||
|
||||
const park = PARK_MAP.get(parkId);
|
||||
if (!park) return c.json({ error: "Park not found" }, 404);
|
||||
|
||||
const ride = getRideBySlug(parkId, slug);
|
||||
if (!ride) {
|
||||
return c.json({ error: "Ride not found or no history yet" }, 404);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const todayLocal = formatLocalDate(now, park.timezone);
|
||||
const since7d = daysAgoIso(todayLocal, 6); // last 7 calendar days inclusive
|
||||
const since30d = daysAgoIso(todayLocal, 29); // last 30 calendar days inclusive
|
||||
|
||||
const today: DailySample[] = getRideSamplesForDay(parkId, ride.qtRideId, todayLocal);
|
||||
const last7d: DailyAggregate[] = getRideDailyAggregates(parkId, ride.qtRideId, since7d);
|
||||
const last30d: DailyAggregate[] = getRideDailyAggregates(parkId, ride.qtRideId, since30d);
|
||||
const daysWith7d = countRideDays(parkId, ride.qtRideId, since7d);
|
||||
const daysWith30d = countRideDays(parkId, ride.qtRideId, since30d);
|
||||
|
||||
// Best-effort current live state from the shared cache (no upstream fetch
|
||||
// — the cache is warmed by Tier-5 every 5 min and by the /rides route).
|
||||
const liveRides = liveRidesCache.get(parkId);
|
||||
const liveMatch = liveRides?.rides.find((r) => slugifyRideName(r.name) === slug) ?? null;
|
||||
const fastLaneCacheEntry = fastLaneCache.get(parkId);
|
||||
const flMatch = liveMatch && fastLaneCacheEntry ? lookupFastLane(liveMatch.name, fastLaneCacheEntry) : null;
|
||||
|
||||
// Operating-window gate. Queue-Times keeps reporting yesterday's last wait
|
||||
// with isOpen=true overnight, so we override to closed when we're outside
|
||||
// the park's hours — same behaviour as the /rides route.
|
||||
const todayData = getDayData(parkId, getTodayLocal());
|
||||
const withinWindow = todayData?.hoursLabel
|
||||
? isWithinOperatingWindow(todayData.hoursLabel, park.timezone)
|
||||
: false;
|
||||
const liveIsOpen = Boolean(liveMatch?.isOpen) && withinWindow;
|
||||
|
||||
c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=120");
|
||||
return c.json({
|
||||
park: {
|
||||
id: park.id,
|
||||
name: park.name,
|
||||
shortName: park.shortName,
|
||||
timezone: park.timezone,
|
||||
},
|
||||
ride: {
|
||||
qtRideId: ride.qtRideId,
|
||||
slug: ride.slug,
|
||||
name: ride.name,
|
||||
isCoaster: ride.isCoaster,
|
||||
hasFastLane: ride.hasFastLane,
|
||||
firstSeen: ride.firstSeen,
|
||||
lastSeen: ride.lastSeen,
|
||||
},
|
||||
live: liveMatch
|
||||
? {
|
||||
isOpen: liveIsOpen,
|
||||
// Prefer Six Flags' regular wait when fresh — QT lags around open.
|
||||
waitMinutes: liveIsOpen
|
||||
? (flMatch?.regularMinutes ?? liveMatch.waitMinutes)
|
||||
: 0,
|
||||
hasFastLane: Boolean(flMatch?.hasFastLane),
|
||||
fastLaneMinutes: liveIsOpen ? (flMatch?.fastLaneMinutes ?? null) : null,
|
||||
lastUpdated: liveMatch.lastUpdated,
|
||||
}
|
||||
: null,
|
||||
todayLocal,
|
||||
today,
|
||||
last7d,
|
||||
last30d,
|
||||
coverage: {
|
||||
daysWith7d,
|
||||
daysWith30d,
|
||||
todaySampleCount: today.length,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Hono } from "hono";
|
||||
import { PARK_MAP } from "../../../lib/parks";
|
||||
import { QUEUE_TIMES_IDS } from "../../../lib/queue-times-map";
|
||||
import { getCoasterSet } from "../../../lib/coaster-data";
|
||||
import { getTodayLocal, getOperatingStatus } from "../../../lib/env";
|
||||
import { fetchLiveRides } from "../../../lib/scrapers/queuetimes";
|
||||
import { scrapeRidesForDay } from "../../../lib/scrapers/sixflags";
|
||||
import { fetchFastLaneWaits, lookupFastLane } from "../../../lib/scrapers/sixflags-waittimes";
|
||||
import { getDayData } from "../db/queries";
|
||||
import { liveRidesCache, fastLaneCache } from "../services/live-cache";
|
||||
import { slugifyRideName } from "../../../lib/ride-slug";
|
||||
import type { LiveRidesResult } from "../../../lib/scrapers/queuetimes";
|
||||
import { log } from "../log";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/:id/rides", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const park = PARK_MAP.get(id);
|
||||
if (!park) return c.json({ error: "Park not found" }, 404);
|
||||
|
||||
const today = getTodayLocal();
|
||||
const todayData = getDayData(id, today);
|
||||
const operatingStatus = todayData?.hoursLabel
|
||||
? getOperatingStatus(todayData.hoursLabel, park.timezone)
|
||||
: "closed";
|
||||
const withinWindow = operatingStatus !== "closed";
|
||||
|
||||
const queueTimesId = QUEUE_TIMES_IDS[id];
|
||||
let liveRides: LiveRidesResult | null = null;
|
||||
|
||||
if (queueTimesId) {
|
||||
liveRides = liveRidesCache.get(id);
|
||||
if (liveRides === null) {
|
||||
const coasterSet = getCoasterSet(id);
|
||||
liveRides = await fetchLiveRides(queueTimesId, coasterSet).catch((err: Error) => {
|
||||
log.warn("rides", "fetchLiveRides failed", { park: id, err: err.message });
|
||||
return null;
|
||||
});
|
||||
if (liveRides) liveRidesCache.set(id, liveRides);
|
||||
}
|
||||
|
||||
if (liveRides && !withinWindow) {
|
||||
liveRides = {
|
||||
...liveRides,
|
||||
rides: liveRides.rides.map((r) => ({ ...r, isOpen: false, waitMinutes: 0 })),
|
||||
};
|
||||
}
|
||||
|
||||
// Join Fast Lane waits (Six Flags /wait-times) onto the Queue-Times rides by name.
|
||||
if (liveRides) {
|
||||
let fastLane = fastLaneCache.get(id);
|
||||
if (fastLane === null) {
|
||||
fastLane = await fetchFastLaneWaits(park.apiId).catch((err: Error) => {
|
||||
log.warn("rides", "fetchFastLaneWaits failed", { park: id, err: err.message });
|
||||
return null;
|
||||
});
|
||||
if (fastLane) fastLaneCache.set(id, fastLane);
|
||||
}
|
||||
if (fastLane) {
|
||||
const fl = fastLane;
|
||||
liveRides = {
|
||||
...liveRides,
|
||||
rides: liveRides.rides.map((r) => {
|
||||
const match = lookupFastLane(r.name, fl);
|
||||
if (!match) return r;
|
||||
// Prefer Six Flags' regular wait when fresh — QT lags around
|
||||
// park-open. Keep QT's value when SF has no regular data.
|
||||
const waitMinutes =
|
||||
r.isOpen && match.regularMinutes !== null
|
||||
? match.regularMinutes
|
||||
: r.waitMinutes;
|
||||
return {
|
||||
...r,
|
||||
waitMinutes,
|
||||
hasFastLane: match.hasFastLane,
|
||||
fastLaneMinutes: r.isOpen ? match.fastLaneMinutes : null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Attach URL slug to each live ride so the frontend can build links
|
||||
// without re-slugifying. Same algorithm the sampler uses for the rides table.
|
||||
if (liveRides) {
|
||||
liveRides = {
|
||||
...liveRides,
|
||||
rides: liveRides.rides.map((r) => ({ ...r, slug: slugifyRideName(r.name) })),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Only flag weather delay during scheduled hours — during the post-close
|
||||
// wind-down buffer, all-rides-closed is just normal closing, not weather.
|
||||
const isWeatherDelay =
|
||||
operatingStatus === "open" &&
|
||||
liveRides !== null &&
|
||||
liveRides.rides.length > 0 &&
|
||||
liveRides.rides.every((r) => !r.isOpen);
|
||||
|
||||
let scheduleFallback = null;
|
||||
if (!liveRides) {
|
||||
scheduleFallback = await scrapeRidesForDay(park.apiId, today).catch((err: Error) => {
|
||||
log.warn("rides", "scrapeRidesForDay failed", { park: id, err: err.message });
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=120");
|
||||
return c.json({
|
||||
parkId: id,
|
||||
today,
|
||||
parkOpenToday: !!(todayData?.isOpen && todayData?.hoursLabel),
|
||||
withinWindow,
|
||||
isWeatherDelay,
|
||||
liveRides,
|
||||
scheduleFallback,
|
||||
});
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Hono } from "hono";
|
||||
import { scrapeToday, scrapeCurrentMonth, scrapeUpcomingMonths, scrapeFullYear } from "../services/scraper";
|
||||
import { sampleAllOpenParks } from "../services/wait-sampler";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.post("/trigger", async (c) => {
|
||||
const scope = c.req.query("scope") ?? "today";
|
||||
|
||||
let result;
|
||||
switch (scope) {
|
||||
case "today":
|
||||
result = await scrapeToday();
|
||||
break;
|
||||
case "month":
|
||||
result = await scrapeCurrentMonth();
|
||||
break;
|
||||
case "upcoming":
|
||||
result = await scrapeUpcomingMonths();
|
||||
break;
|
||||
case "full":
|
||||
result = await scrapeFullYear();
|
||||
break;
|
||||
case "force":
|
||||
result = await scrapeFullYear(true);
|
||||
break;
|
||||
case "samples":
|
||||
result = await sampleAllOpenParks();
|
||||
break;
|
||||
default:
|
||||
return c.json({ error: "Invalid scope. Use: today, month, upcoming, full, force, samples" }, 400);
|
||||
}
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Hono } from "hono";
|
||||
import { PARKS } from "../../../lib/parks";
|
||||
import { getLastScrapeTime, getParkDayCount } from "../db/queries";
|
||||
import { getLastScrapeResult } from "../services/scraper";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/", (c) => {
|
||||
return c.json({
|
||||
status: "ok",
|
||||
uptime: Math.floor(process.uptime()),
|
||||
parks: PARKS.length,
|
||||
database: {
|
||||
totalDays: getParkDayCount(),
|
||||
lastScrape: getLastScrapeTime(),
|
||||
},
|
||||
lastScrapeResult: getLastScrapeResult(),
|
||||
});
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,35 @@
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export class TtlCache<T> {
|
||||
private store = new Map<string, CacheEntry<T>>();
|
||||
|
||||
constructor(private defaultTtlMs: number) {}
|
||||
|
||||
get(key: string): T | null {
|
||||
const entry = this.store.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.store.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
set(key: string, data: T, ttlMs?: number): void {
|
||||
this.store.set(key, {
|
||||
data,
|
||||
expiresAt: Date.now() + (ttlMs ?? this.defaultTtlMs),
|
||||
});
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.store.clear();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.store.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Shared in-memory caches for live ride data.
|
||||
*
|
||||
* Both the on-demand `/api/parks/:id/rides` route and the Tier-5 wait
|
||||
* sampler hit the same upstream APIs (Queue-Times + Six Flags wait-times).
|
||||
* Sharing the cache means the sampler "warms" it every 5 minutes, so
|
||||
* subsequent user requests hit a fresh cache without re-fetching.
|
||||
*/
|
||||
|
||||
import { TtlCache } from "./cache";
|
||||
import type { LiveRidesResult } from "../../../lib/scrapers/queuetimes";
|
||||
import type { FastLaneResult } from "../../../lib/scrapers/sixflags-waittimes";
|
||||
|
||||
const FIVE_MIN = 5 * 60 * 1000;
|
||||
|
||||
export const liveRidesCache = new TtlCache<LiveRidesResult | null>(FIVE_MIN);
|
||||
export const fastLaneCache = new TtlCache<FastLaneResult | null>(FIVE_MIN);
|
||||
@@ -0,0 +1,102 @@
|
||||
import cron from "node-cron";
|
||||
import { scrapeToday, scrapeCurrentMonth, scrapeUpcomingMonths, scrapeFullYear } from "./scraper";
|
||||
import { sampleAllOpenParks } from "./wait-sampler";
|
||||
import { getParkDayCount } from "../db/queries";
|
||||
import { log } from "../log";
|
||||
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* Wrap a cron handler so a still-running prior tick is skipped instead of
|
||||
* racing it. Each tier gets its own latch — better-sqlite3's per-statement
|
||||
* locking handles row-level safety, but skipping overlap avoids redundant
|
||||
* upstream API calls and the resulting rate-limit risk.
|
||||
*/
|
||||
function withLatch(tag: string, fn: () => Promise<void>): () => Promise<void> {
|
||||
let running = false;
|
||||
return async () => {
|
||||
if (running) {
|
||||
log.warn(tag, "previous run still in progress, skipping tick");
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
log.error(tag, "tick failed", { err: (err as Error).message });
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function startScheduler(): void {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
cron.schedule(
|
||||
"0 * * 3-12 *",
|
||||
withLatch("scheduler.tier1", async () => {
|
||||
log.info("scheduler.tier1", "scraping today");
|
||||
await scrapeToday();
|
||||
}),
|
||||
);
|
||||
|
||||
cron.schedule(
|
||||
"0 */6 * * *",
|
||||
withLatch("scheduler.tier2", async () => {
|
||||
log.info("scheduler.tier2", "scraping current month");
|
||||
await scrapeCurrentMonth();
|
||||
}),
|
||||
);
|
||||
|
||||
cron.schedule(
|
||||
"0 3,15 * * *",
|
||||
withLatch("scheduler.tier3", async () => {
|
||||
log.info("scheduler.tier3", "scraping upcoming months");
|
||||
await scrapeUpcomingMonths();
|
||||
}),
|
||||
);
|
||||
|
||||
cron.schedule(
|
||||
"0 3 * * *",
|
||||
withLatch("scheduler.tier4", async () => {
|
||||
log.info("scheduler.tier4", "scraping full year");
|
||||
await scrapeFullYear();
|
||||
}),
|
||||
);
|
||||
|
||||
cron.schedule(
|
||||
"*/5 * * * *",
|
||||
withLatch("scheduler.tier5", async () => {
|
||||
const r = await sampleAllOpenParks();
|
||||
log.info("scheduler.tier5", "sample run complete", {
|
||||
parksSampled: r.parksSampled,
|
||||
parksSkipped: r.parksSkipped,
|
||||
samplesWritten: r.samplesWritten,
|
||||
weatherDelayed: r.weatherDelayed,
|
||||
errors: r.errors,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
log.info("scheduler", "cron jobs registered", {
|
||||
tiers: "tier1=hourly(Mar-Dec) tier2=6h tier3=3am+3pm tier4=3am-daily tier5=5min",
|
||||
});
|
||||
|
||||
const existingRows = getParkDayCount();
|
||||
if (existingRows < 50) {
|
||||
log.info("scheduler", "running startup scrape", { existingRows });
|
||||
scrapeToday()
|
||||
.then((r) => {
|
||||
log.info("scheduler.startup", "today done", { fetched: r.fetched, updated: r.updated, errors: r.errors });
|
||||
return scrapeFullYear();
|
||||
})
|
||||
.then((r) => {
|
||||
log.info("scheduler.startup", "full-year done", { fetched: r.fetched, skipped: r.skipped, errors: r.errors });
|
||||
})
|
||||
.catch((err) => log.error("scheduler.startup", "scrape failed", { err: (err as Error).message }));
|
||||
} else {
|
||||
log.info("scheduler", "skipping startup scrape — relying on cron", { existingRows });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { PARKS } from "../../../lib/parks";
|
||||
import { scrapeMonth, fetchToday, RateLimitError } from "../../../lib/scrapers/sixflags";
|
||||
import { upsertDay, isMonthScraped, getDayData, transact } from "../db/queries";
|
||||
import { config } from "../config";
|
||||
import { log } from "../log";
|
||||
|
||||
const DELAY_MS = 1000;
|
||||
const STALE_AFTER_MS = config.parkHoursStalenessHours * 60 * 60 * 1000;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
export interface ScrapeResult {
|
||||
scope: string;
|
||||
fetched: number;
|
||||
skipped: number;
|
||||
errors: number;
|
||||
updated: number;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
}
|
||||
|
||||
let lastScrapeResult: ScrapeResult | null = null;
|
||||
|
||||
export function getLastScrapeResult(): ScrapeResult | null {
|
||||
return lastScrapeResult;
|
||||
}
|
||||
|
||||
export async function scrapeToday(): Promise<ScrapeResult> {
|
||||
const startedAt = new Date().toISOString();
|
||||
let fetched = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const park of PARKS) {
|
||||
try {
|
||||
const live = await fetchToday(park.apiId);
|
||||
if (!live) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
fetched++;
|
||||
|
||||
const existing = getDayData(park.id, live.date);
|
||||
if (
|
||||
existing &&
|
||||
existing.isOpen === live.isOpen &&
|
||||
existing.hoursLabel === (live.hoursLabel ?? null) &&
|
||||
existing.specialType === (live.specialType ?? null)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
upsertDay(park.id, live.date, live.isOpen, live.hoursLabel, live.specialType);
|
||||
updated++;
|
||||
log.info("scrape.today", "updated", {
|
||||
park: park.shortName,
|
||||
isOpen: live.isOpen,
|
||||
hours: live.hoursLabel ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
errors++;
|
||||
log.warn("scrape.today", "park failed", {
|
||||
park: park.shortName,
|
||||
err: (err as Error).message,
|
||||
});
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
const result: ScrapeResult = {
|
||||
scope: "today",
|
||||
fetched,
|
||||
skipped,
|
||||
errors,
|
||||
updated,
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
};
|
||||
lastScrapeResult = result;
|
||||
log.info("scrape.today", "done", { fetched, updated, skipped, errors });
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function scrapeMonths(monthList: { year: number; month: number }[], force = false): Promise<ScrapeResult> {
|
||||
const startedAt = new Date().toISOString();
|
||||
let fetched = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const park of PARKS) {
|
||||
for (const { year, month } of monthList) {
|
||||
if (!force && isMonthScraped(park.id, year, month, STALE_AFTER_MS)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const days = await scrapeMonth(park.apiId, year, month);
|
||||
transact(() => {
|
||||
for (const d of days) {
|
||||
upsertDay(park.id, d.date, d.isOpen, d.hoursLabel, d.specialType);
|
||||
}
|
||||
});
|
||||
fetched++;
|
||||
log.info("scrape.month", "scraped", {
|
||||
park: park.shortName,
|
||||
month: `${year}-${String(month).padStart(2, "0")}`,
|
||||
openDays: days.filter((d) => d.isOpen).length,
|
||||
});
|
||||
} catch (err) {
|
||||
errors++;
|
||||
if (err instanceof RateLimitError) {
|
||||
log.warn("scrape.month", "rate limited", { park: park.shortName });
|
||||
} else {
|
||||
log.error("scrape.month", "failed", {
|
||||
park: park.shortName,
|
||||
month: `${year}-${String(month).padStart(2, "0")}`,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
await sleep(DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
const result: ScrapeResult = {
|
||||
scope: `months(${monthList.map((m) => `${m.year}-${String(m.month).padStart(2, "0")}`).join(",")})`,
|
||||
fetched,
|
||||
skipped,
|
||||
errors,
|
||||
updated: fetched,
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
};
|
||||
lastScrapeResult = result;
|
||||
log.info("scrape.month", "done", { fetched, skipped, errors });
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function scrapeCurrentMonth(): Promise<ScrapeResult> {
|
||||
const now = new Date();
|
||||
return scrapeMonths([{ year: now.getFullYear(), month: now.getMonth() + 1 }]);
|
||||
}
|
||||
|
||||
export async function scrapeUpcomingMonths(): Promise<ScrapeResult> {
|
||||
const now = new Date();
|
||||
const current = { year: now.getFullYear(), month: now.getMonth() + 1 };
|
||||
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const nextMonth = { year: next.getFullYear(), month: next.getMonth() + 1 };
|
||||
return scrapeMonths([current, nextMonth]);
|
||||
}
|
||||
|
||||
export async function scrapeFullYear(force = false): Promise<ScrapeResult> {
|
||||
const year = new Date().getFullYear();
|
||||
const months = Array.from({ length: 12 }, (_, i) => ({ year, month: i + 1 }));
|
||||
return scrapeMonths(months, force);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Tier-5 wait-time sampler. Runs every 5 minutes via cron.
|
||||
*
|
||||
* For each park whose `park_days` row marks it open today:
|
||||
* 1. Fetch live rides (Queue-Times) and Fast Lane waits (Six Flags) —
|
||||
* reusing the shared TtlCache so we don't double-hit upstreams.
|
||||
* 2. Detect the "weather delay" case (rides exist but all closed); skip
|
||||
* writes for that park so it doesn't pollute uptime stats.
|
||||
* 3. Upsert each ride into `rides` and INSERT OR IGNORE a sample row.
|
||||
*
|
||||
* Park-local date/time are computed at insert time via Intl.DateTimeFormat
|
||||
* with the park's IANA timezone — DST-safe, automatic.
|
||||
*
|
||||
* Parks are fanned out in chunks of 6 to bound concurrency.
|
||||
*/
|
||||
|
||||
import { PARKS } from "../../../lib/parks";
|
||||
import type { Park } from "../../../lib/scrapers/types";
|
||||
import { QUEUE_TIMES_IDS } from "../../../lib/queue-times-map";
|
||||
import { getCoasterSet } from "../../../lib/coaster-data";
|
||||
import { getTodayLocal, getOperatingStatus } from "../../../lib/env";
|
||||
import { fetchLiveRides } from "../../../lib/scrapers/queuetimes";
|
||||
import { fetchFastLaneWaits, lookupFastLane } from "../../../lib/scrapers/sixflags-waittimes";
|
||||
import { slugifyRideName } from "../../../lib/ride-slug";
|
||||
import { formatLocalDate, formatLocalTime } from "../../../lib/timezone";
|
||||
import { liveRidesCache, fastLaneCache } from "./live-cache";
|
||||
import { getDayData, upsertRide, insertSample, transact } from "../db/queries";
|
||||
import { log } from "../log";
|
||||
|
||||
const PARALLEL_CHUNK = 6;
|
||||
|
||||
export interface SampleRunResult {
|
||||
parksSampled: number;
|
||||
parksSkipped: number;
|
||||
ridesUpserted: number;
|
||||
samplesWritten: number;
|
||||
weatherDelayed: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
async function samplePark(
|
||||
park: Park,
|
||||
now: Date,
|
||||
status: "open" | "closing",
|
||||
): Promise<{
|
||||
ridesUpserted: number;
|
||||
samplesWritten: number;
|
||||
weatherDelayed: boolean;
|
||||
error: boolean;
|
||||
}> {
|
||||
const queueTimesId = QUEUE_TIMES_IDS[park.id];
|
||||
if (!queueTimesId) {
|
||||
return { ridesUpserted: 0, samplesWritten: 0, weatherDelayed: false, error: false };
|
||||
}
|
||||
|
||||
try {
|
||||
// Live rides — reuse cache; fetch on miss.
|
||||
let liveRides = liveRidesCache.get(park.id);
|
||||
if (liveRides === null) {
|
||||
const coasterSet = getCoasterSet(park.id);
|
||||
liveRides = await fetchLiveRides(queueTimesId, coasterSet).catch((err: Error) => {
|
||||
log.warn("wait-sampler", "fetchLiveRides failed", { park: park.id, err: err.message });
|
||||
return null;
|
||||
});
|
||||
if (liveRides) liveRidesCache.set(park.id, liveRides);
|
||||
}
|
||||
if (!liveRides || liveRides.rides.length === 0) {
|
||||
return { ridesUpserted: 0, samplesWritten: 0, weatherDelayed: false, error: false };
|
||||
}
|
||||
|
||||
// Weather-delay heuristic — skip writing so uptime stays honest. Only
|
||||
// applies during scheduled hours; in the post-close wind-down buffer,
|
||||
// rides legitimately finish operating, so don't mislabel that as weather.
|
||||
const anyOpen = liveRides.rides.some((r) => r.isOpen);
|
||||
if (!anyOpen) {
|
||||
return {
|
||||
ridesUpserted: 0,
|
||||
samplesWritten: 0,
|
||||
weatherDelayed: status === "open",
|
||||
error: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Fast Lane — reuse cache; fetch on miss.
|
||||
let fastLane = fastLaneCache.get(park.id);
|
||||
if (fastLane === null) {
|
||||
fastLane = await fetchFastLaneWaits(park.apiId).catch((err: Error) => {
|
||||
log.warn("wait-sampler", "fetchFastLaneWaits failed", { park: park.id, err: err.message });
|
||||
return null;
|
||||
});
|
||||
if (fastLane) fastLaneCache.set(park.id, fastLane);
|
||||
}
|
||||
|
||||
const recordedAt = now.toISOString();
|
||||
const localDate = formatLocalDate(now, park.timezone);
|
||||
const localTime = formatLocalTime(now, park.timezone);
|
||||
|
||||
let ridesUpserted = 0;
|
||||
let samplesWritten = 0;
|
||||
|
||||
transact(() => {
|
||||
for (const ride of liveRides!.rides) {
|
||||
if (!ride.qtRideId) continue;
|
||||
const slug = slugifyRideName(ride.name);
|
||||
const sfMatch = fastLane ? lookupFastLane(ride.name, fastLane) : null;
|
||||
const hasFastLane = Boolean(sfMatch?.hasFastLane);
|
||||
// Prefer Six Flags' regular wait when it's fresh — QT often lags
|
||||
// around park-open. Fall back to QT's value when SF has no data.
|
||||
const waitMinutes = ride.isOpen
|
||||
? (sfMatch?.regularMinutes ?? ride.waitMinutes)
|
||||
: null;
|
||||
const fastLaneMinutes =
|
||||
ride.isOpen && sfMatch ? sfMatch.fastLaneMinutes : null;
|
||||
|
||||
upsertRide(
|
||||
park.id,
|
||||
ride.qtRideId,
|
||||
slug,
|
||||
ride.name,
|
||||
ride.isCoaster,
|
||||
hasFastLane,
|
||||
recordedAt,
|
||||
);
|
||||
ridesUpserted++;
|
||||
|
||||
insertSample(
|
||||
park.id,
|
||||
ride.qtRideId,
|
||||
recordedAt,
|
||||
localDate,
|
||||
localTime,
|
||||
ride.isOpen,
|
||||
waitMinutes,
|
||||
fastLaneMinutes,
|
||||
);
|
||||
samplesWritten++;
|
||||
}
|
||||
});
|
||||
|
||||
return { ridesUpserted, samplesWritten, weatherDelayed: false, error: false };
|
||||
} catch (err) {
|
||||
log.error("wait-sampler", "sampling failed", {
|
||||
park: park.id,
|
||||
err: (err as Error).message,
|
||||
});
|
||||
return { ridesUpserted: 0, samplesWritten: 0, weatherDelayed: false, error: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sampleAllOpenParks(): Promise<SampleRunResult> {
|
||||
const today = getTodayLocal();
|
||||
const now = new Date();
|
||||
const result: SampleRunResult = {
|
||||
parksSampled: 0,
|
||||
parksSkipped: 0,
|
||||
ridesUpserted: 0,
|
||||
samplesWritten: 0,
|
||||
weatherDelayed: 0,
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
// Filter to parks that are open today AND currently within their operating
|
||||
// window (including the 1-hour post-close wind-down). Queue-Times keeps
|
||||
// reporting yesterday's last wait with isOpen=true overnight, so the
|
||||
// per-ride open check isn't enough on its own — we'd otherwise pollute
|
||||
// uptime stats with phantom open samples between close and the next
|
||||
// morning's first refresh.
|
||||
const openParks: Array<{ park: Park; status: "open" | "closing" }> = [];
|
||||
for (const park of PARKS) {
|
||||
const day = getDayData(park.id, today);
|
||||
if (!day?.isOpen || !day.hoursLabel) continue;
|
||||
const status = getOperatingStatus(day.hoursLabel, park.timezone);
|
||||
if (status === "closed") continue;
|
||||
openParks.push({ park, status });
|
||||
}
|
||||
result.parksSkipped = PARKS.length - openParks.length;
|
||||
|
||||
// Fan out in bounded chunks so we don't blast 24 requests in parallel.
|
||||
for (let i = 0; i < openParks.length; i += PARALLEL_CHUNK) {
|
||||
const chunk = openParks.slice(i, i + PARALLEL_CHUNK);
|
||||
const chunkResults = await Promise.all(chunk.map(({ park, status }) => samplePark(park, now, status)));
|
||||
for (const r of chunkResults) {
|
||||
if (r.error) result.errors++;
|
||||
else if (r.weatherDelayed) result.weatherDelayed++;
|
||||
else if (r.samplesWritten > 0) result.parksSampled++;
|
||||
result.ridesUpserted += r.ridesUpserted;
|
||||
result.samplesWritten += r.samplesWritten;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Aggregation query tests.
|
||||
*
|
||||
* Spins up an in-memory better-sqlite3 instance with the production schema,
|
||||
* seeds known samples, and verifies the daily aggregation produces the right
|
||||
* avg / max / uptime / sample_count. Locks the SQL semantics so a refactor
|
||||
* can't silently change the meaning of "uptime" or how closed samples are
|
||||
* filtered.
|
||||
*
|
||||
* Run with: npm --prefix backend test
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE ride_wait_samples (
|
||||
park_id TEXT NOT NULL,
|
||||
qt_ride_id INTEGER NOT NULL,
|
||||
recorded_at TEXT NOT NULL,
|
||||
local_date TEXT NOT NULL,
|
||||
local_time TEXT NOT NULL,
|
||||
is_open INTEGER NOT NULL,
|
||||
wait_minutes INTEGER,
|
||||
fast_lane_minutes INTEGER,
|
||||
PRIMARY KEY (park_id, qt_ride_id, recorded_at)
|
||||
);
|
||||
`;
|
||||
|
||||
const AGGREGATE_QUERY = `
|
||||
SELECT local_date,
|
||||
AVG(CASE WHEN is_open = 1 THEN wait_minutes END) AS avg_wait,
|
||||
MAX(CASE WHEN is_open = 1 THEN wait_minutes END) AS max_wait,
|
||||
AVG(CASE WHEN is_open = 1 THEN fast_lane_minutes END) AS avg_fl,
|
||||
MAX(CASE WHEN is_open = 1 THEN fast_lane_minutes END) AS max_fl,
|
||||
CAST(SUM(is_open) AS REAL) / COUNT(*) AS uptime_pct,
|
||||
COUNT(*) AS sample_count
|
||||
FROM ride_wait_samples
|
||||
WHERE park_id = ? AND qt_ride_id = ? AND local_date >= ?
|
||||
GROUP BY local_date
|
||||
ORDER BY local_date
|
||||
`;
|
||||
|
||||
interface Sample {
|
||||
parkId: string;
|
||||
qtRideId: number;
|
||||
recordedAt: string;
|
||||
localDate: string;
|
||||
localTime: string;
|
||||
isOpen: boolean;
|
||||
waitMinutes: number | null;
|
||||
fastLaneMinutes: number | null;
|
||||
}
|
||||
|
||||
function setup(samples: Sample[]) {
|
||||
const db = new Database(":memory:");
|
||||
db.exec(SCHEMA);
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO ride_wait_samples
|
||||
(park_id, qt_ride_id, recorded_at, local_date, local_time, is_open, wait_minutes, fast_lane_minutes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
for (const s of samples) {
|
||||
stmt.run(
|
||||
s.parkId,
|
||||
s.qtRideId,
|
||||
s.recordedAt,
|
||||
s.localDate,
|
||||
s.localTime,
|
||||
s.isOpen ? 1 : 0,
|
||||
s.waitMinutes,
|
||||
s.fastLaneMinutes,
|
||||
);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
interface AggregateRow {
|
||||
local_date: string;
|
||||
avg_wait: number | null;
|
||||
max_wait: number | null;
|
||||
avg_fl: number | null;
|
||||
max_fl: number | null;
|
||||
uptime_pct: number;
|
||||
sample_count: number;
|
||||
}
|
||||
|
||||
test("avg and max are computed only over open samples", () => {
|
||||
const db = setup([
|
||||
s("p", 1, "2026-05-29", "10:00", true, 10, null),
|
||||
s("p", 1, "2026-05-29", "10:05", true, 20, null),
|
||||
s("p", 1, "2026-05-29", "10:10", true, 30, null),
|
||||
s("p", 1, "2026-05-29", "10:15", false, null, null),
|
||||
s("p", 1, "2026-05-29", "10:20", true, 40, null),
|
||||
]);
|
||||
const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-29") as AggregateRow[];
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].max_wait, 40);
|
||||
assert.equal(rows[0].avg_wait, (10 + 20 + 30 + 40) / 4);
|
||||
assert.equal(rows[0].sample_count, 5);
|
||||
});
|
||||
|
||||
test("uptime_pct is open_samples / total_samples", () => {
|
||||
const db = setup([
|
||||
s("p", 1, "2026-05-29", "10:00", true, 10, null),
|
||||
s("p", 1, "2026-05-29", "10:05", true, 20, null),
|
||||
s("p", 1, "2026-05-29", "10:10", false, null, null),
|
||||
s("p", 1, "2026-05-29", "10:15", false, null, null),
|
||||
]);
|
||||
const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-29") as AggregateRow[];
|
||||
assert.equal(rows[0].uptime_pct, 0.5);
|
||||
});
|
||||
|
||||
test("an all-closed day reports uptime 0 and null waits", () => {
|
||||
const db = setup([
|
||||
s("p", 1, "2026-05-29", "10:00", false, null, null),
|
||||
s("p", 1, "2026-05-29", "10:05", false, null, null),
|
||||
]);
|
||||
const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-29") as AggregateRow[];
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].uptime_pct, 0);
|
||||
assert.equal(rows[0].avg_wait, null);
|
||||
assert.equal(rows[0].max_wait, null);
|
||||
});
|
||||
|
||||
test("multiple days are returned separately, ordered by local_date", () => {
|
||||
const db = setup([
|
||||
s("p", 1, "2026-05-29", "10:00", true, 10, null),
|
||||
s("p", 1, "2026-05-28", "10:00", true, 50, null),
|
||||
s("p", 1, "2026-05-30", "10:00", true, 30, null),
|
||||
]);
|
||||
const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-28") as AggregateRow[];
|
||||
assert.equal(rows.length, 3);
|
||||
assert.deepEqual(rows.map((r) => r.local_date), ["2026-05-28", "2026-05-29", "2026-05-30"]);
|
||||
assert.deepEqual(rows.map((r) => r.max_wait), [50, 10, 30]);
|
||||
});
|
||||
|
||||
test("local_date filter excludes earlier days", () => {
|
||||
const db = setup([
|
||||
s("p", 1, "2026-05-20", "10:00", true, 99, null), // before window
|
||||
s("p", 1, "2026-05-29", "10:00", true, 10, null),
|
||||
]);
|
||||
const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-29") as AggregateRow[];
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].local_date, "2026-05-29");
|
||||
});
|
||||
|
||||
test("fast lane stats roll up independently of regular wait stats", () => {
|
||||
const db = setup([
|
||||
s("p", 1, "2026-05-29", "10:00", true, 30, 5),
|
||||
s("p", 1, "2026-05-29", "10:05", true, 40, 10),
|
||||
s("p", 1, "2026-05-29", "10:10", true, 50, null), // open but no FL data
|
||||
]);
|
||||
const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-29") as AggregateRow[];
|
||||
assert.equal(rows[0].max_fl, 10);
|
||||
assert.equal(rows[0].avg_fl, 7.5); // averaged over the two non-null FL samples
|
||||
assert.equal(rows[0].max_wait, 50);
|
||||
});
|
||||
|
||||
test("parks and rides are isolated", () => {
|
||||
const db = setup([
|
||||
s("p1", 1, "2026-05-29", "10:00", true, 10, null),
|
||||
s("p1", 2, "2026-05-29", "10:00", true, 99, null),
|
||||
s("p2", 1, "2026-05-29", "10:00", true, 50, null),
|
||||
]);
|
||||
const r = db.prepare(AGGREGATE_QUERY).all("p1", 1, "2026-05-29") as AggregateRow[];
|
||||
assert.equal(r[0].max_wait, 10);
|
||||
assert.equal(r[0].sample_count, 1);
|
||||
});
|
||||
|
||||
// ── Helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function s(
|
||||
parkId: string,
|
||||
qtRideId: number,
|
||||
localDate: string,
|
||||
localTime: string,
|
||||
isOpen: boolean,
|
||||
waitMinutes: number | null,
|
||||
fastLaneMinutes: number | null,
|
||||
): Sample {
|
||||
return {
|
||||
parkId,
|
||||
qtRideId,
|
||||
recordedAt: `${localDate}T${localTime}:00Z`,
|
||||
localDate,
|
||||
localTime,
|
||||
isOpen,
|
||||
waitMinutes,
|
||||
fastLaneMinutes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "..",
|
||||
"baseUrl": "..",
|
||||
"paths": {
|
||||
"@lib/*": ["lib/*"]
|
||||
},
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "../lib/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "../lib/api.ts"]
|
||||
}
|
||||
@@ -1,19 +1,12 @@
|
||||
"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}`);
|
||||
}, []);
|
||||
|
||||
// The selected week is stored in a server-readable cookie (tcWeek), so the
|
||||
// home page already renders the right week without us needing to pass it
|
||||
// through the URL.
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
href="/"
|
||||
className="park-name-link"
|
||||
style={{
|
||||
display: "flex",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { WeekNav } from "./WeekNav";
|
||||
import { Legend } from "./Legend";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { PARKS, groupByRegion } from "@/lib/parks";
|
||||
import type { DayData } from "@/lib/db";
|
||||
import type { DayData } from "@/lib/types";
|
||||
|
||||
const REFRESH_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
|
||||
const OPEN_REFRESH_BUFFER_MS = 30_000; // 30s after opening time before hitting the API
|
||||
@@ -109,11 +109,6 @@ export function HomePageClient({
|
||||
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);
|
||||
|
||||
+144
-56
@@ -1,18 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import type { LiveRidesResult, LiveRide } from "@/lib/scrapers/queuetimes";
|
||||
import { slugifyRideName } from "@/lib/ride-slug";
|
||||
|
||||
interface LiveRidePanelProps {
|
||||
parkId: string;
|
||||
liveRides: LiveRidesResult;
|
||||
parkOpenToday: boolean;
|
||||
isWeatherDelay?: boolean;
|
||||
}
|
||||
|
||||
export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: LiveRidePanelProps) {
|
||||
export function LiveRidePanel({ parkId, liveRides, parkOpenToday, isWeatherDelay }: LiveRidePanelProps) {
|
||||
const { rides } = liveRides;
|
||||
const hasCoasters = rides.some((r) => r.isCoaster);
|
||||
const hasFastLane = rides.some((r) => r.hasFastLane);
|
||||
const [coastersOnly, setCoastersOnly] = useState(false);
|
||||
const [fastLaneMode, setFastLaneMode] = useState(false);
|
||||
|
||||
// Pre-select coaster filter if Coaster Mode is enabled on the homepage.
|
||||
useEffect(() => {
|
||||
@@ -21,6 +26,21 @@ export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: Live
|
||||
}
|
||||
}, [hasCoasters]);
|
||||
|
||||
// Restore Fast Lane mode from a previous visit.
|
||||
useEffect(() => {
|
||||
if (hasFastLane && localStorage.getItem("fastLaneMode") === "true") {
|
||||
setFastLaneMode(true);
|
||||
}
|
||||
}, [hasFastLane]);
|
||||
|
||||
const toggleFastLane = () => {
|
||||
setFastLaneMode((v) => {
|
||||
const next = !v;
|
||||
localStorage.setItem("fastLaneMode", String(next));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const visible = coastersOnly ? rides.filter((r) => r.isCoaster) : rides;
|
||||
const openRides = visible.filter((r) => r.isOpen);
|
||||
const closedRides = visible.filter((r) => !r.isOpen);
|
||||
@@ -94,35 +114,69 @@ export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: Live
|
||||
</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>
|
||||
{/* Toggle group — pushed to the right */}
|
||||
{(hasCoasters || hasFastLane) && (
|
||||
<div style={{ marginLeft: "auto", display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{/* Fast Lane toggle — swaps shown waits to Fast Lane numbers */}
|
||||
{hasFastLane && (
|
||||
<button
|
||||
onClick={toggleFastLane}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
padding: "4px 12px",
|
||||
borderRadius: 20,
|
||||
border: fastLaneMode
|
||||
? "1px solid var(--color-accent)"
|
||||
: "1px solid var(--color-border)",
|
||||
background: fastLaneMode
|
||||
? "var(--color-accent-muted)"
|
||||
: "var(--color-surface)",
|
||||
color: fastLaneMode
|
||||
? "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",
|
||||
}}
|
||||
>
|
||||
⚡ Fast Lane
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Coaster toggle — only shown when the park has categorised coasters */}
|
||||
{hasCoasters && (
|
||||
<button
|
||||
onClick={() => setCoastersOnly((v) => !v)}
|
||||
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",
|
||||
}}
|
||||
>
|
||||
🎢 Coasters only
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -132,19 +186,22 @@ export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: Live
|
||||
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} />)}
|
||||
{openRides.map((ride) => <RideRow key={ride.name} parkId={parkId} ride={ride} fastLaneMode={fastLaneMode} />)}
|
||||
{closedRides.map((ride) => <RideRow key={ride.name} parkId={parkId} ride={ride} fastLaneMode={fastLaneMode} />)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RideRow({ ride }: { ride: LiveRide }) {
|
||||
function RideRow({ parkId, ride, fastLaneMode }: { parkId: string; ride: LiveRide; fastLaneMode: boolean }) {
|
||||
const showWait = ride.isOpen && ride.waitMinutes > 0;
|
||||
const fastLaneActive = fastLaneMode && ride.hasFastLane;
|
||||
const flWait = ride.fastLaneMinutes ?? 0;
|
||||
const slug = ride.slug ?? slugifyRideName(ride.name);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
<Link href={`/park/${parkId}/ride/${slug}`} className="ride-row-link" style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
@@ -154,6 +211,9 @@ function RideRow({ ride }: { ride: LiveRide }) {
|
||||
border: `1px solid ${ride.isOpen ? "var(--color-open-border)" : "var(--color-border)"}`,
|
||||
borderRadius: 8,
|
||||
opacity: ride.isOpen ? 1 : 0.6,
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||
<span style={{
|
||||
@@ -174,28 +234,56 @@ function RideRow({ ride }: { ride: LiveRide }) {
|
||||
{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>
|
||||
{/* Fast Lane mode: swap the shown wait to the Fast Lane number */}
|
||||
{fastLaneMode ? (
|
||||
ride.isOpen && fastLaneActive ? (
|
||||
<span style={{
|
||||
fontSize: "0.72rem",
|
||||
color: "var(--color-accent)",
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
{flWait > 0 ? `⚡ ${flWait} min` : "⚡ walk-on"}
|
||||
</span>
|
||||
) : ride.isOpen ? (
|
||||
<span style={{
|
||||
fontSize: "0.68rem",
|
||||
color: "var(--color-text-muted)",
|
||||
fontWeight: 500,
|
||||
flexShrink: 0,
|
||||
opacity: 0.7,
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
no Fast Lane
|
||||
</span>
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{ride.isOpen && !showWait && (
|
||||
<span style={{
|
||||
fontSize: "0.68rem",
|
||||
color: "var(--color-open-text)",
|
||||
fontWeight: 500,
|
||||
flexShrink: 0,
|
||||
opacity: 0.7,
|
||||
}}>
|
||||
walk-on
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Park } from "@/lib/scrapers/types";
|
||||
import type { DayData } from "@/lib/db";
|
||||
import type { DayData } from "@/lib/types";
|
||||
import type { Region } from "@/lib/parks";
|
||||
import { ParkCard } from "./ParkCard";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import type { Park } from "@/lib/scrapers/types";
|
||||
import type { DayData } from "@/lib/db";
|
||||
import type { DayData } from "@/lib/types";
|
||||
import { getTimezoneAbbr } from "@/lib/env";
|
||||
|
||||
interface ParkCardProps {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import type { DayData } from "@/lib/db";
|
||||
import type { DayData } from "@/lib/types";
|
||||
import { getTimezoneAbbr } from "@/lib/env";
|
||||
|
||||
interface ParkMonthCalendarProps {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Fragment } from "react";
|
||||
import Link from "next/link";
|
||||
import type { Park } from "@/lib/scrapers/types";
|
||||
import type { DayData } from "@/lib/db";
|
||||
import type { DayData } from "@/lib/types";
|
||||
import type { Region } from "@/lib/parks";
|
||||
import { getTodayLocal, getTimezoneAbbr } from "@/lib/env";
|
||||
|
||||
|
||||
+14
-5
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { setWeek, clearWeek } from "@/app/actions/week";
|
||||
|
||||
interface WeekNavProps {
|
||||
weekStart: string; // YYYY-MM-DD (Sunday)
|
||||
@@ -25,16 +25,25 @@ function formatLabel(dates: string[]): string {
|
||||
return `${startStr} – ${endStr}`;
|
||||
}
|
||||
|
||||
function formatDateLocal(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function shiftWeek(weekStart: string, delta: number): string {
|
||||
const d = new Date(weekStart + "T00:00:00");
|
||||
d.setDate(d.getDate() + delta * 7);
|
||||
return d.toISOString().slice(0, 10);
|
||||
return formatDateLocal(d);
|
||||
}
|
||||
|
||||
export function WeekNav({ weekStart, weekDates, isCurrentWeek }: WeekNavProps) {
|
||||
const router = useRouter();
|
||||
const nav = (delta: number) => {
|
||||
router.push(`/?week=${shiftWeek(weekStart, delta)}`);
|
||||
void setWeek(shiftWeek(weekStart, delta));
|
||||
};
|
||||
const jumpToToday = () => {
|
||||
void clearWeek();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -61,7 +70,7 @@ export function WeekNav({ weekStart, weekDates, isCurrentWeek }: WeekNavProps) {
|
||||
|
||||
{!isCurrentWeek && (
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
onClick={jumpToToday}
|
||||
aria-label="Jump to current week"
|
||||
style={todayBtnStyle}
|
||||
onMouseOver={(e) => Object.assign((e.target as HTMLElement).style, todayBtnHover)}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
interface Props {
|
||||
/** Mean uptime across the window, 0–1. */
|
||||
uptime: number;
|
||||
sampleCount: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function colorFor(uptime: number): { fg: string; bg: string; border: string } {
|
||||
if (uptime >= 0.95) return { fg: "var(--color-open-text)", bg: "var(--color-open-bg)", border: "var(--color-open-border)" };
|
||||
if (uptime >= 0.8) return { fg: "var(--color-closing-text)", bg: "var(--color-closing-bg)", border: "var(--color-closing-border)" };
|
||||
return { fg: "var(--color-accent)", bg: "var(--color-accent-muted)", border: "var(--color-accent)" };
|
||||
}
|
||||
|
||||
export default function UptimePill({ uptime, sampleCount, label }: Props) {
|
||||
const { fg, bg, border } = colorFor(uptime);
|
||||
const pct = (uptime * 100).toFixed(uptime >= 0.999 ? 0 : 1);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: 4,
|
||||
padding: "12px 16px",
|
||||
background: bg,
|
||||
border: `1px solid ${border}`,
|
||||
borderRadius: 8,
|
||||
minWidth: 140,
|
||||
}}>
|
||||
<span style={{ fontSize: "0.65rem", textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--color-text-muted)" }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ fontSize: "1.5rem", fontWeight: 700, color: fg, lineHeight: 1 }}>
|
||||
{pct}%
|
||||
</span>
|
||||
<span style={{ fontSize: "0.65rem", color: "var(--color-text-dim)" }}>
|
||||
{sampleCount.toLocaleString()} sample{sampleCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
ReferenceArea,
|
||||
} from "recharts";
|
||||
import { computeOutages, outageLookup, formatOutageDuration } from "@/lib/outage";
|
||||
|
||||
export interface TodaySample {
|
||||
recordedAt: string;
|
||||
localTime: string;
|
||||
isOpen: boolean;
|
||||
waitMinutes: number | null;
|
||||
fastLaneMinutes: number | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
samples: TodaySample[];
|
||||
hasFastLane: boolean;
|
||||
}
|
||||
|
||||
const TIME_FMT = new Intl.DateTimeFormat([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
export default function WaitTimeTodayChart({ samples, hasFastLane }: Props) {
|
||||
const outages = computeOutages(samples);
|
||||
const outageByTime = outageLookup(samples, outages);
|
||||
|
||||
// Show the Fast Lane line if either the ride metadata flags it as a Fast
|
||||
// Lane ride OR we have any actual Fast Lane data in today's samples. The
|
||||
// metadata flag can lag (or read false even when SF is reporting waits),
|
||||
// so the data-driven check rescues those rides.
|
||||
const showFastLane = hasFastLane || samples.some((s) => s.fastLaneMinutes !== null);
|
||||
|
||||
// X-axis time is rendered in the viewer's local timezone (Intl with no
|
||||
// tz arg) so an Eastern-time user sees ET regardless of which park.
|
||||
// Each point also carries its outage (if any) so the custom tooltip can
|
||||
// render "Outage #N — Hh Mm" without re-scanning.
|
||||
const data = samples.map((s) => {
|
||||
const time = TIME_FMT.format(new Date(s.recordedAt));
|
||||
const outage = !s.isOpen ? outageByTime.get(time) ?? null : null;
|
||||
return {
|
||||
time,
|
||||
wait: s.isOpen ? s.waitMinutes : null,
|
||||
fl: s.isOpen ? s.fastLaneMinutes : null,
|
||||
outageN: outage?.n ?? null,
|
||||
outageDurationMin: outage?.durationMin ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const tickInterval = Math.max(1, Math.floor(data.length / 8));
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height: 280 }}>
|
||||
<ResponsiveContainer>
|
||||
{/* top margin leaves room for outage labels rendered with position="top" */}
|
||||
<LineChart data={data} margin={{ top: 24, right: 16, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid stroke="#272727" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="#737373"
|
||||
tick={{ fontSize: 11, fill: "#737373" }}
|
||||
interval={tickInterval}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#737373"
|
||||
tick={{ fontSize: 11, fill: "#737373" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
domain={[0, "auto"]}
|
||||
padding={{ bottom: 6 }}
|
||||
label={{ value: "min", position: "insideLeft", angle: -90, offset: 16, style: { fontSize: 10, fill: "#737373" } }}
|
||||
/>
|
||||
{/* Outage markers — render before the lines so they sit underneath. */}
|
||||
{outages.map((o) => (
|
||||
<ReferenceArea
|
||||
key={`outage-${o.n}`}
|
||||
x1={o.startTimeLabel}
|
||||
x2={o.endTimeLabel}
|
||||
fill="#ff4d8d"
|
||||
fillOpacity={0.08}
|
||||
stroke="#ff4d8d"
|
||||
strokeOpacity={0.35}
|
||||
strokeDasharray="3 3"
|
||||
ifOverflow="extendDomain"
|
||||
label={{
|
||||
value: `#${o.n} · ${formatOutageDuration(o.durationMin)}`,
|
||||
position: "top",
|
||||
fill: "#ff4d8d",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
offset: 4,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<Tooltip content={<RideTooltip hasFastLane={showFastLane} />} />
|
||||
<Legend wrapperStyle={{ fontSize: "0.72rem", paddingTop: 4 }} />
|
||||
<Line
|
||||
name="Wait"
|
||||
type="monotone"
|
||||
dataKey="wait"
|
||||
stroke="#4ade80"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
{showFastLane && (
|
||||
<Line
|
||||
name="Fast Lane"
|
||||
type="monotone"
|
||||
dataKey="fl"
|
||||
stroke="#ff4d8d"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Custom tooltip ─────────────────────────────────────────────────────────
|
||||
// When hovering on an outage span, replace the standard wait/Fast Lane rows
|
||||
// with a single "Outage #N — duration" line. Otherwise show the per-series
|
||||
// values, formatting 0 explicitly as "walk-on".
|
||||
|
||||
interface TooltipDatum {
|
||||
outageN: number | null;
|
||||
outageDurationMin: number | null;
|
||||
}
|
||||
|
||||
interface TooltipEntry {
|
||||
value?: number | null;
|
||||
name?: string | number;
|
||||
dataKey?: string | number;
|
||||
color?: string;
|
||||
payload?: TooltipDatum;
|
||||
}
|
||||
|
||||
interface RideTooltipProps {
|
||||
active?: boolean;
|
||||
payload?: TooltipEntry[];
|
||||
label?: string | number;
|
||||
hasFastLane: boolean;
|
||||
}
|
||||
|
||||
function RideTooltip({ active, payload, label, hasFastLane }: RideTooltipProps) {
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
|
||||
const datum = payload[0]?.payload;
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: "#1c1c1c",
|
||||
border: "1px solid #333",
|
||||
borderRadius: 6,
|
||||
fontSize: "0.75rem",
|
||||
color: "#f5f5f5",
|
||||
padding: "8px 10px",
|
||||
lineHeight: 1.4,
|
||||
};
|
||||
|
||||
if (datum?.outageN && datum.outageDurationMin !== null) {
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
<div style={{ color: "#b0b0b0", fontSize: "0.68rem", marginBottom: 2 }}>{label}</div>
|
||||
<div style={{ color: "#ff4d8d", fontWeight: 600 }}>
|
||||
Outage #{datum.outageN} — {formatOutageDuration(datum.outageDurationMin)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
<div style={{ color: "#b0b0b0", fontSize: "0.68rem", marginBottom: 4 }}>{label}</div>
|
||||
{payload.map((entry, i) => {
|
||||
const v = entry.value;
|
||||
const display =
|
||||
v === null || v === undefined
|
||||
? "—"
|
||||
: v === 0
|
||||
? "walk-on"
|
||||
: `${v} min`;
|
||||
const dotColor = entry.color ?? "#fff";
|
||||
return (
|
||||
<div key={String(entry.dataKey ?? i)} style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ width: 8, height: 2, background: dotColor, borderRadius: 1 }} />
|
||||
<span style={{ color: "#b0b0b0", flex: 1 }}>{entry.name}</span>
|
||||
<span style={{ fontWeight: 600 }}>{display}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!hasFastLane && null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts";
|
||||
|
||||
export interface DailyAggregate {
|
||||
localDate: string;
|
||||
avgWait: number | null;
|
||||
maxWait: number | null;
|
||||
avgFastLane: number | null;
|
||||
maxFastLane: number | null;
|
||||
uptimePct: number;
|
||||
sampleCount: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: DailyAggregate[];
|
||||
hasFastLane: boolean;
|
||||
mode: "regular" | "fastLane";
|
||||
}
|
||||
|
||||
function shortDay(localDate: string): string {
|
||||
// "2026-05-29" → "May 29"
|
||||
const [, m, d] = localDate.split("-");
|
||||
const month = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][parseInt(m, 10)];
|
||||
return `${month} ${parseInt(d, 10)}`;
|
||||
}
|
||||
|
||||
export default function WeeklyStatsChart({ data, hasFastLane, mode }: Props) {
|
||||
const showFastLane = mode === "fastLane" && hasFastLane;
|
||||
const avgColor = showFastLane ? "#ff4d8d" : "#4ade80";
|
||||
const maxColor = showFastLane ? "#ff4d8d" : "#22c55e";
|
||||
const maxFill = showFastLane ? "#3d0f22" : "#0a1a0d";
|
||||
const chartData = data.map((d) => ({
|
||||
day: shortDay(d.localDate),
|
||||
avg: showFastLane
|
||||
? (d.avgFastLane !== null ? Math.round(d.avgFastLane) : null)
|
||||
: (d.avgWait !== null ? Math.round(d.avgWait) : null),
|
||||
max: showFastLane ? d.maxFastLane : d.maxWait,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height: 240 }}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 16, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid stroke="#272727" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="day" stroke="#737373" tick={{ fontSize: 11, fill: "#737373" }} tickLine={false} />
|
||||
<YAxis stroke="#737373" tick={{ fontSize: 11, fill: "#737373" }} tickLine={false} axisLine={false} />
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(255,255,255,0.04)" }}
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "#1c1c1c",
|
||||
border: "1px solid #333",
|
||||
borderRadius: 6,
|
||||
fontSize: "0.75rem",
|
||||
padding: "8px 12px",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#b0b0b0", marginBottom: 2 }}>{label}</div>
|
||||
{payload.map((entry) => {
|
||||
const color = entry.dataKey === "max" ? maxColor : avgColor;
|
||||
const v = entry.value;
|
||||
const display = v === null || v === undefined ? "—" : `${v} min`;
|
||||
return (
|
||||
<div key={String(entry.dataKey)} style={{ color }}>
|
||||
{entry.name} : {display}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: "0.72rem", paddingTop: 4 }}
|
||||
formatter={(value) =>
|
||||
value === "Max" ? <span style={{ color: maxColor }}>{value}</span> : value
|
||||
}
|
||||
/>
|
||||
<Bar name="Avg" dataKey="avg" fill={avgColor} radius={[3, 3, 0, 0]} isAnimationActive={false} />
|
||||
<Bar name="Max" dataKey="max" fill={maxFill} stroke={maxColor} strokeWidth={1} radius={[3, 3, 0, 0]} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
+35
-7
@@ -1,24 +1,52 @@
|
||||
services:
|
||||
web:
|
||||
image: gitea.thewrightserver.net/josh/sixflagssupercalendar:web
|
||||
image: gitea.thewrightserver.net/josh/thoosiecalendar:web
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- park_data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- BACKEND_URL=http://backend:3001
|
||||
- TZ=America/New_York
|
||||
restart: unless-stopped
|
||||
mem_limit: 512m
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
scraper:
|
||||
image: gitea.thewrightserver.net/josh/sixflagssupercalendar:scraper
|
||||
backend:
|
||||
image: gitea.thewrightserver.net/josh/thoosiecalendar:backend
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- park_data:/app/data
|
||||
- park_data:/app/backend/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- TZ=America/New_York
|
||||
- PARK_HOURS_STALENESS_HOURS=72
|
||||
- COASTER_STALENESS_HOURS=720
|
||||
restart: unless-stopped
|
||||
mem_limit: 512m
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3001/api/status"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
park_data:
|
||||
|
||||
+599
@@ -0,0 +1,599 @@
|
||||
# API Reference
|
||||
|
||||
> See also: [Architecture](ARCHITECTURE.md) | [Operations](OPERATIONS.md) | [Development](DEVELOPMENT.md)
|
||||
|
||||
## Base URL
|
||||
|
||||
| Context | URL |
|
||||
|---------|-----|
|
||||
| Local development | `http://localhost:3001` |
|
||||
| Docker internal (web → backend) | `http://backend:3001` |
|
||||
| External access (via host port) | `http://<host>:3001` |
|
||||
|
||||
## Authentication
|
||||
|
||||
None. All endpoints are public and unauthenticated. The scrape trigger endpoint is also unprotected -- restrict access at the network/proxy level if needed.
|
||||
|
||||
## Rate limiting
|
||||
|
||||
Every endpoint is gated by a fixed-window per-IP counter (`backend/src/middleware/rate-limit.ts`).
|
||||
|
||||
| Header / Body | Value |
|
||||
|---------------|-------|
|
||||
| Limit | `RATE_LIMIT_PER_MIN` env var, default `60` requests/minute |
|
||||
| Window | 60 seconds, per client IP (resolved via `x-forwarded-for` → `x-real-ip` → socket address) |
|
||||
| Over-limit response | `429 Too Many Requests` |
|
||||
| Body | `{ "error": "Too many requests" }` |
|
||||
| Response header | `Retry-After: <seconds>` — how long until the window resets |
|
||||
|
||||
Behind a reverse proxy, make sure `x-forwarded-for` is set or every request will appear to come from the proxy's own IP.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### GET /api/calendar/week
|
||||
|
||||
Returns a 7-day calendar for all parks, starting from the given Sunday.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Param | Required | Format | Description |
|
||||
|-------|----------|--------|-------------|
|
||||
| `start` | Yes | `YYYY-MM-DD` | Week start date (should be a Sunday) |
|
||||
|
||||
**Cache:** `Cache-Control: public, max-age=120, stale-while-revalidate=300`
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"weekStart": "2026-04-19",
|
||||
"weekDates": ["2026-04-19", "2026-04-20", "2026-04-21", "2026-04-22", "2026-04-23", "2026-04-24", "2026-04-25"],
|
||||
"today": "2026-04-23",
|
||||
"isCurrentWeek": true,
|
||||
"data": {
|
||||
"cedarpoint": {
|
||||
"2026-04-23": {
|
||||
"isOpen": true,
|
||||
"hoursLabel": "10am – 8pm",
|
||||
"specialType": null
|
||||
},
|
||||
"2026-04-24": {
|
||||
"isOpen": false,
|
||||
"hoursLabel": null,
|
||||
"specialType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"rideCounts": {
|
||||
"cedarpoint": 42,
|
||||
"greatadventure": 38
|
||||
},
|
||||
"coasterCounts": {
|
||||
"cedarpoint": 12,
|
||||
"greatadventure": 9
|
||||
},
|
||||
"openParkIds": ["cedarpoint", "greatadventure"],
|
||||
"closingParkIds": [],
|
||||
"weatherDelayParkIds": [],
|
||||
"hasCoasterData": true,
|
||||
"scrapedCount": 168
|
||||
}
|
||||
```
|
||||
|
||||
**Response fields:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `weekStart` | `string` | Echo of the `start` parameter |
|
||||
| `weekDates` | `string[]` | Array of 7 date strings (Sun-Sat) |
|
||||
| `today` | `string` | Current date (with 3 AM switchover) |
|
||||
| `isCurrentWeek` | `boolean` | Whether this week contains today |
|
||||
| `data` | `Record<parkId, Record<date, DayData>>` | Schedule data keyed by park ID and date |
|
||||
| `rideCounts` | `Record<parkId, number>` | Number of open rides per park (only for currently operating parks with rides reporting) |
|
||||
| `coasterCounts` | `Record<parkId, number>` | Number of open coasters per park |
|
||||
| `openParkIds` | `string[]` | Parks currently within their operating window |
|
||||
| `closingParkIds` | `string[]` | Parks past close but within 1-hour wind-down |
|
||||
| `weatherDelayParkIds` | `string[]` | Parks within window but all rides closed |
|
||||
| `hasCoasterData` | `boolean` | Always `true` (coaster data is static) |
|
||||
| `scrapedCount` | `number` | Total day records returned (sanity check for empty database) |
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | Body | Condition |
|
||||
|--------|------|-----------|
|
||||
| 400 | `{ "error": "Missing or invalid ?start=YYYY-MM-DD" }` | Missing or malformed `start` parameter |
|
||||
|
||||
---
|
||||
|
||||
### GET /api/calendar/:parkId/month
|
||||
|
||||
Returns a month calendar for a single park.
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
| Param | Description |
|
||||
|-------|-------------|
|
||||
| `parkId` | Park identifier (e.g. `cedarpoint`, `greatadventure`) |
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Param | Required | Format | Description |
|
||||
|-------|----------|--------|-------------|
|
||||
| `month` | Yes | `YYYY-MM` | Month to fetch |
|
||||
|
||||
**Cache:** `Cache-Control: public, max-age=300, stale-while-revalidate=600`
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"parkId": "cedarpoint",
|
||||
"year": 2026,
|
||||
"month": 6,
|
||||
"monthData": {
|
||||
"2026-06-01": {
|
||||
"isOpen": true,
|
||||
"hoursLabel": "10am – 10pm",
|
||||
"specialType": null
|
||||
},
|
||||
"2026-06-02": {
|
||||
"isOpen": true,
|
||||
"hoursLabel": "10am – 8pm",
|
||||
"specialType": null
|
||||
}
|
||||
},
|
||||
"today": "2026-04-23"
|
||||
}
|
||||
```
|
||||
|
||||
If viewing the current month, today's data is fetched live from the Six Flags API and merged into the response.
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | Body | Condition |
|
||||
|--------|------|-----------|
|
||||
| 400 | `{ "error": "Missing or invalid ?month=YYYY-MM" }` | Missing or malformed `month` parameter |
|
||||
| 400 | `{ "error": "Month must be 1-12" }` | Month value out of range |
|
||||
|
||||
---
|
||||
|
||||
### GET /api/parks
|
||||
|
||||
Returns metadata for all 24 parks.
|
||||
|
||||
**Cache:** `Cache-Control: public, max-age=3600`
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"parks": [
|
||||
{
|
||||
"id": "cedarpoint",
|
||||
"apiId": 70,
|
||||
"name": "Cedar Point",
|
||||
"shortName": "Cedar Point",
|
||||
"chain": "sixflags",
|
||||
"slug": "cedarpoint",
|
||||
"region": "Midwest",
|
||||
"location": {
|
||||
"lat": 41.4784,
|
||||
"lng": -82.6834,
|
||||
"city": "Sandusky",
|
||||
"state": "OH"
|
||||
},
|
||||
"timezone": "America/New_York",
|
||||
"website": "https://www.sixflags.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/parks/:id
|
||||
|
||||
Returns metadata for a single park.
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
| Param | Description |
|
||||
|-------|-------------|
|
||||
| `id` | Park identifier |
|
||||
|
||||
**Cache:** `Cache-Control: public, max-age=3600`
|
||||
|
||||
**Response:** A single `Park` object (same shape as one element of the `/api/parks` array).
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | Body | Condition |
|
||||
|--------|------|-----------|
|
||||
| 404 | `{ "error": "Park not found" }` | Unknown park ID |
|
||||
|
||||
---
|
||||
|
||||
### GET /api/parks/:id/rides
|
||||
|
||||
Returns live ride status or schedule fallback for a park.
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
| Param | Description |
|
||||
|-------|-------------|
|
||||
| `id` | Park identifier |
|
||||
|
||||
**Cache:** `Cache-Control: public, max-age=60, stale-while-revalidate=120`
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"parkId": "cedarpoint",
|
||||
"today": "2026-04-23",
|
||||
"parkOpenToday": true,
|
||||
"withinWindow": true,
|
||||
"isWeatherDelay": false,
|
||||
"liveRides": {
|
||||
"rides": [
|
||||
{
|
||||
"name": "Steel Vengeance",
|
||||
"slug": "steel-vengeance",
|
||||
"isOpen": true,
|
||||
"waitMinutes": 45,
|
||||
"fastLaneMinutes": 10,
|
||||
"hasFastLane": true,
|
||||
"lastUpdated": "2026-04-23T18:30:00.000Z",
|
||||
"isCoaster": true
|
||||
},
|
||||
{
|
||||
"name": "Millennium Force",
|
||||
"slug": "millennium-force",
|
||||
"isOpen": false,
|
||||
"waitMinutes": 0,
|
||||
"fastLaneMinutes": null,
|
||||
"hasFastLane": true,
|
||||
"lastUpdated": "2026-04-23T18:30:00.000Z",
|
||||
"isCoaster": true
|
||||
}
|
||||
],
|
||||
"fetchedAt": "2026-04-23T18:35:00.000Z"
|
||||
},
|
||||
"scheduleFallback": null
|
||||
}
|
||||
```
|
||||
|
||||
Each ride is enriched from two sources: Queue-Times.com supplies `isOpen` and the base `waitMinutes`, then Six Flags' wait-times feed is joined by name to fill in `fastLaneMinutes` and `hasFastLane`. When both sources have a regular wait for the same ride, the Six Flags value wins (Queue-Times lags around park open). `fastLaneMinutes` is `null` when the ride is closed or has no Fast Lane line. `slug` is the URL-safe identifier used by `/api/parks/:id/rides/:slug`.
|
||||
|
||||
**Response fields:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `parkId` | `string` | Echo of the park ID |
|
||||
| `today` | `string` | Current date (with 3 AM switchover) |
|
||||
| `parkOpenToday` | `boolean` | Whether the park has hours scheduled today |
|
||||
| `withinWindow` | `boolean` | Whether current time is within operating hours |
|
||||
| `isWeatherDelay` | `boolean` | Park is open but all rides are closed |
|
||||
| `liveRides` | `LiveRidesResult \| null` | Queue-Times live data (null if park has no mapping or is outside window) |
|
||||
| `scheduleFallback` | `RidesFetchResult \| null` | Six Flags schedule data (only populated when liveRides is null) |
|
||||
|
||||
**Data priority:**
|
||||
1. If a Queue-Times mapping exists and the park is tracked, `liveRides` is populated.
|
||||
2. If outside the operating window, all rides in `liveRides` are forced to `isOpen: false`.
|
||||
3. If no live data is available, `scheduleFallback` is fetched from the Six Flags schedule API for the nearest open date.
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | Body | Condition |
|
||||
|--------|------|-----------|
|
||||
| 404 | `{ "error": "Park not found" }` | Unknown park ID |
|
||||
|
||||
---
|
||||
|
||||
### GET /api/parks/:parkId/rides/:slug
|
||||
|
||||
Returns metadata + history for a single ride: today's 5-minute wait samples and daily aggregates over the last 7 and 30 calendar days. Everything ships in one round-trip — the frontend renders the Today / 7d / 30d tabs from this single payload.
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
| Param | Description |
|
||||
|-------|-------------|
|
||||
| `parkId` | Park identifier (e.g. `cedarpoint`) |
|
||||
| `slug` | Ride slug, as returned in `LiveRide.slug` or stored in the `rides.slug` column |
|
||||
|
||||
**Cache:** `Cache-Control: public, max-age=60, stale-while-revalidate=120`
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"park": {
|
||||
"id": "cedarpoint",
|
||||
"name": "Cedar Point",
|
||||
"shortName": "Cedar Point",
|
||||
"timezone": "America/New_York"
|
||||
},
|
||||
"ride": {
|
||||
"qtRideId": 257,
|
||||
"slug": "steel-vengeance",
|
||||
"name": "Steel Vengeance",
|
||||
"isCoaster": true,
|
||||
"hasFastLane": true,
|
||||
"firstSeen": "2026-03-15T14:05:00.000Z",
|
||||
"lastSeen": "2026-04-23T18:35:00.000Z"
|
||||
},
|
||||
"live": {
|
||||
"isOpen": true,
|
||||
"waitMinutes": 45,
|
||||
"hasFastLane": true,
|
||||
"fastLaneMinutes": 10,
|
||||
"lastUpdated": "2026-04-23T18:30:00.000Z"
|
||||
},
|
||||
"todayLocal": "2026-04-23",
|
||||
"today": [
|
||||
{
|
||||
"recordedAt": "2026-04-23T14:05:12.000Z",
|
||||
"localTime": "10:05",
|
||||
"isOpen": true,
|
||||
"waitMinutes": 15,
|
||||
"fastLaneMinutes": 5
|
||||
}
|
||||
],
|
||||
"last7d": [
|
||||
{
|
||||
"localDate": "2026-04-17",
|
||||
"avgWait": 38.4,
|
||||
"maxWait": 90,
|
||||
"avgFastLane": 9.1,
|
||||
"maxFastLane": 25,
|
||||
"uptimePct": 0.94,
|
||||
"sampleCount": 132
|
||||
}
|
||||
],
|
||||
"last30d": [],
|
||||
"coverage": {
|
||||
"daysWith7d": 6,
|
||||
"daysWith30d": 23,
|
||||
"todaySampleCount": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response fields:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `park` | `{ id, name, shortName, timezone }` | Park identity (timezone is the IANA tz used for sample bucketing) |
|
||||
| `ride` | `RideRecord` | Canonical row from the `rides` table |
|
||||
| `live` | `LiveRideSummary \| null` | Best-effort current state pulled from the shared in-memory cache. No upstream fetch — populated by the rides route and Tier-5 sampler. `null` if no recent observation exists. |
|
||||
| `todayLocal` | `string` | Today's date in the park's timezone |
|
||||
| `today` | `DailySample[]` | Per-sample series for `todayLocal`, ordered by `recordedAt` |
|
||||
| `last7d` | `DailyAggregate[]` | One row per `local_date` over the last 7 calendar days (inclusive of today) |
|
||||
| `last30d` | `DailyAggregate[]` | Same aggregates over 30 days |
|
||||
| `coverage.daysWith7d` | `number` | Distinct dates with samples in the 7-day window — use to gate the 7d tab |
|
||||
| `coverage.daysWith30d` | `number` | Distinct dates with samples in the 30-day window |
|
||||
| `coverage.todaySampleCount` | `number` | Number of samples already collected today |
|
||||
|
||||
`DailySample` and `DailyAggregate` shapes are listed under [Data Types](#data-types).
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | Body | Condition |
|
||||
|--------|------|-----------|
|
||||
| 404 | `{ "error": "Park not found" }` | Unknown park ID |
|
||||
| 404 | `{ "error": "Ride not found or no history yet" }` | Slug doesn't match any row in `rides` for this park (Tier-5 hasn't seen the ride yet, or the slug is wrong) |
|
||||
|
||||
---
|
||||
|
||||
### GET /api/status
|
||||
|
||||
Health check endpoint with database statistics.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"uptime": 86400,
|
||||
"parks": 24,
|
||||
"database": {
|
||||
"totalDays": 8760,
|
||||
"lastScrape": "2026-04-23T14:00:12.000Z"
|
||||
},
|
||||
"lastScrapeResult": {
|
||||
"scope": "today",
|
||||
"fetched": 24,
|
||||
"skipped": 0,
|
||||
"errors": 0,
|
||||
"updated": 3,
|
||||
"startedAt": "2026-04-23T14:00:00.000Z",
|
||||
"finishedAt": "2026-04-23T14:00:12.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `status` | `string` | Always `"ok"` |
|
||||
| `uptime` | `number` | Process uptime in seconds |
|
||||
| `parks` | `number` | Number of tracked parks (24) |
|
||||
| `database.totalDays` | `number` | Total rows in `park_days` table |
|
||||
| `database.lastScrape` | `string \| null` | ISO timestamp of the most recent `scraped_at` value |
|
||||
| `lastScrapeResult` | `ScrapeResult \| null` | Result of the last completed scrape (null if none has run yet) |
|
||||
|
||||
---
|
||||
|
||||
### POST /api/scrape/trigger
|
||||
|
||||
Manually triggers a data scrape. See [Operations > Manual Scraping](OPERATIONS.md#manual-scraping) for detailed usage.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Param | Required | Default | Values | Description |
|
||||
|-------|----------|---------|--------|-------------|
|
||||
| `scope` | No | `today` | `today`, `month`, `upcoming`, `full`, `force` | What to scrape |
|
||||
|
||||
**Scope details:**
|
||||
|
||||
| Scope | What it scrapes | Staleness check | Inter-park delay |
|
||||
|-------|----------------|-----------------|------------------|
|
||||
| `today` | Today's hours only | No (uses diff-before-write) | 500ms |
|
||||
| `month` | Current month | Yes (skips if fresh) | 1000ms |
|
||||
| `upcoming` | Current + next month | Yes | 1000ms |
|
||||
| `full` | All 12 months | Yes | 1000ms |
|
||||
| `force` | All 12 months | **No** (ignores staleness) | 1000ms |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"scope": "today",
|
||||
"fetched": 24,
|
||||
"skipped": 0,
|
||||
"errors": 0,
|
||||
"updated": 3,
|
||||
"startedAt": "2026-04-23T14:00:00.000Z",
|
||||
"finishedAt": "2026-04-23T14:00:12.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | Body | Condition |
|
||||
|--------|------|-----------|
|
||||
| 400 | `{ "error": "Invalid scope. Use: today, month, upcoming, full, force" }` | Unknown scope value |
|
||||
|
||||
---
|
||||
|
||||
## Data Types
|
||||
|
||||
### DayData
|
||||
|
||||
Core schedule data for a single park on a single day.
|
||||
|
||||
```typescript
|
||||
interface DayData {
|
||||
isOpen: boolean; // Whether the park is open
|
||||
hoursLabel: string | null; // e.g. "10am – 6pm", null when closed
|
||||
specialType: string | null; // "passholder_preview" or null
|
||||
}
|
||||
```
|
||||
|
||||
### Park
|
||||
|
||||
Park metadata (static, defined in `lib/parks.ts`).
|
||||
|
||||
```typescript
|
||||
interface Park {
|
||||
id: string; // Unique identifier (e.g. "cedarpoint")
|
||||
apiId: number; // Six Flags CloudFront API park ID
|
||||
name: string; // Full display name
|
||||
shortName: string; // Abbreviated name
|
||||
chain: string; // "sixflags"
|
||||
slug: string; // URL-safe slug
|
||||
region: string; // Geographic region
|
||||
location: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
city: string;
|
||||
state: string;
|
||||
};
|
||||
timezone: string; // IANA timezone (e.g. "America/New_York")
|
||||
website: string; // Park website URL
|
||||
}
|
||||
```
|
||||
|
||||
### LiveRide
|
||||
|
||||
A single ride from the Queue-Times.com API.
|
||||
|
||||
```typescript
|
||||
interface LiveRide {
|
||||
name: string; // Ride display name
|
||||
slug: string; // URL-safe slug for /api/parks/:id/rides/:slug
|
||||
isOpen: boolean; // Currently operating
|
||||
waitMinutes: number; // Current regular wait (0 if closed)
|
||||
fastLaneMinutes?: number | null; // Fast Lane wait (null when closed or no Fast Lane line)
|
||||
hasFastLane?: boolean; // Ride has a Fast Lane offering per Six Flags
|
||||
lastUpdated: string; // ISO 8601 timestamp from Queue-Times
|
||||
isCoaster: boolean; // Classified as a roller coaster via RCDB data
|
||||
}
|
||||
```
|
||||
|
||||
### LiveRidesResult
|
||||
|
||||
Container for live ride data.
|
||||
|
||||
```typescript
|
||||
interface LiveRidesResult {
|
||||
rides: LiveRide[]; // All rides, sorted: open first, then alphabetical
|
||||
fetchedAt: string; // ISO timestamp of when we fetched from Queue-Times
|
||||
}
|
||||
```
|
||||
|
||||
### RidesFetchResult
|
||||
|
||||
Schedule-based ride data (fallback when live data is unavailable).
|
||||
|
||||
```typescript
|
||||
interface RidesFetchResult {
|
||||
rides: RideStatus[]; // Rides with scheduled open/close times
|
||||
dataDate: string; // YYYY-MM-DD the data came from (may differ from requested date)
|
||||
isExact: boolean; // true if dataDate matches requested date
|
||||
parkHoursLabel?: string; // Park-level hours for the data date
|
||||
}
|
||||
|
||||
interface RideStatus {
|
||||
name: string;
|
||||
isOpen: boolean; // Has scheduled operating hours
|
||||
hoursLabel?: string; // e.g. "10am – 10pm"
|
||||
}
|
||||
```
|
||||
|
||||
### DailySample
|
||||
|
||||
A single wait-time observation recorded by the Tier-5 sampler.
|
||||
|
||||
```typescript
|
||||
interface DailySample {
|
||||
recordedAt: string; // ISO 8601 UTC timestamp
|
||||
localTime: string; // HH:MM in the park's timezone
|
||||
isOpen: boolean; // Ride open at this sample
|
||||
waitMinutes: number | null; // Regular wait, null when unobserved
|
||||
fastLaneMinutes: number | null; // Fast Lane wait, null when no Fast Lane or unobserved
|
||||
}
|
||||
```
|
||||
|
||||
### DailyAggregate
|
||||
|
||||
Per-day statistics computed in SQL from `ride_wait_samples`. Only open samples contribute to wait averages.
|
||||
|
||||
```typescript
|
||||
interface DailyAggregate {
|
||||
localDate: string; // YYYY-MM-DD in the park's timezone
|
||||
avgWait: number | null; // Mean wait_minutes across open samples
|
||||
maxWait: number | null; // Highest wait_minutes across open samples
|
||||
avgFastLane: number | null; // Mean fast_lane_minutes across open samples
|
||||
maxFastLane: number | null; // Highest fast_lane_minutes across open samples
|
||||
uptimePct: number; // Fraction of samples with is_open=1 (0..1)
|
||||
sampleCount: number; // Total samples for the day
|
||||
}
|
||||
```
|
||||
|
||||
### ScrapeResult
|
||||
|
||||
Result of a scraping operation.
|
||||
|
||||
```typescript
|
||||
interface ScrapeResult {
|
||||
scope: string; // What was scraped (e.g. "today", "months(2026-04)")
|
||||
fetched: number; // API calls made successfully
|
||||
skipped: number; // Skipped due to staleness or null response
|
||||
errors: number; // Failed API calls
|
||||
updated: number; // Database rows written
|
||||
startedAt: string; // ISO timestamp
|
||||
finishedAt: string; // ISO timestamp
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,597 @@
|
||||
# Architecture
|
||||
|
||||
> See also: [Operations](OPERATIONS.md) | [API Reference](API.md) | [Development](DEVELOPMENT.md)
|
||||
|
||||
## Overview
|
||||
|
||||
Thoosie Calendar is a full-stack web application that displays operating hours and live ride status for all 24 Six Flags Entertainment Group theme parks (including former Cedar Fair properties). The system scrapes schedule data from the Six Flags internal API on a tiered cron schedule, stores it in SQLite, and serves it through a Hono REST API. A Next.js frontend renders the data as a week-by-week calendar with live ride counts and park status indicators.
|
||||
|
||||
The core architectural principle is **strict separation**: the frontend is a pure presentation layer that makes zero direct database or external API calls. All data flows through the backend.
|
||||
|
||||
---
|
||||
|
||||
## System Diagram
|
||||
|
||||
```
|
||||
Internet
|
||||
|
|
||||
+--------v--------+
|
||||
| Reverse Proxy |
|
||||
| (external) |
|
||||
+---+--------+----+
|
||||
| |
|
||||
:3000 | | :3001
|
||||
+----v----+ | +----v---------+
|
||||
| web | | | backend |
|
||||
| Next.js |--+-->| Hono |
|
||||
| React | | + SQLite |
|
||||
| SSR | | + node-cron |
|
||||
+---------+ +------+-------+
|
||||
|
|
||||
+-------------+-------------+
|
||||
| |
|
||||
+-----------v-----------+ +-----------v-----------+
|
||||
| Six Flags CloudFront | | Queue-Times.com |
|
||||
| operating-hours API | | queue_times.json API |
|
||||
+-----------------------+ +------------------------+
|
||||
```
|
||||
|
||||
**Containers:**
|
||||
|
||||
| Container | Port | Role |
|
||||
|-----------|------|------|
|
||||
| `web` | 3000 | Next.js standalone server -- SSR pages, static assets, ISR revalidation |
|
||||
| `backend` | 3001 | Hono API server -- REST endpoints, SQLite database, cron scheduler, external API calls |
|
||||
|
||||
The web container reaches the backend via Docker internal networking (`http://backend:3001`). Both are independent images built from a single multi-stage Dockerfile.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Technology | Version | Purpose |
|
||||
|------------|---------|---------|
|
||||
| Next.js | 15 | App Router, Server Components, standalone output for Docker |
|
||||
| React | 19 | UI rendering (Server + Client components) |
|
||||
| Tailwind CSS | 4 | Styling via `@theme {}` CSS variables (no config file) |
|
||||
| TypeScript | 5 | Type safety across frontend and backend |
|
||||
| Hono | 4.7 | Lightweight HTTP framework for backend API |
|
||||
| better-sqlite3 | -- | Synchronous SQLite driver with native bindings |
|
||||
| node-cron | 3 | Tiered cron scheduling for data scraping |
|
||||
| Node.js | 22 | Runtime for both containers |
|
||||
| Docker | -- | Multi-stage builds producing two minimal images |
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
├── app/ # Next.js App Router
|
||||
│ ├── page.tsx # Home page (week calendar, server component)
|
||||
│ ├── park/[id]/page.tsx # Park detail page (month calendar + rides)
|
||||
│ ├── park/[id]/error.tsx # Per-route error boundary
|
||||
│ ├── park/[id]/ride/[slug]/page.tsx # Ride detail + history page
|
||||
│ ├── layout.tsx # Root layout with metadata
|
||||
│ ├── loading.tsx # Skeleton UI for streaming/suspense
|
||||
│ ├── error.tsx # Top-level error boundary (client)
|
||||
│ ├── not-found.tsx # 404 page
|
||||
│ └── globals.css # Tailwind v4 theme + custom CSS variables
|
||||
│
|
||||
├── components/ # React components
|
||||
│ ├── HomePageClient.tsx # Client component: state, refresh, keyboard nav
|
||||
│ ├── WeekCalendar.tsx # Desktop week table (server component)
|
||||
│ ├── MobileCardList.tsx # Mobile card layout
|
||||
│ ├── ParkCard.tsx # Individual park card
|
||||
│ ├── ParkMonthCalendar.tsx # Month grid for park detail page
|
||||
│ ├── LiveRidePanel.tsx # Live ride status with wait times + Fast Lane toggle (client)
|
||||
│ ├── WeekNav.tsx # Week navigation arrows (client)
|
||||
│ ├── Legend.tsx # Status color legend
|
||||
│ ├── EmptyState.tsx # Shown when no data is scraped
|
||||
│ ├── BackToCalendarLink.tsx # Navigation helper (client)
|
||||
│ └── charts/ # Recharts-based charts (client components)
|
||||
│ ├── WaitTimeTodayChart.tsx # Today's 5-min samples with outage shading
|
||||
│ ├── WeeklyStatsChart.tsx # 7d / 30d daily aggregates
|
||||
│ └── UptimePill.tsx # Compact uptime % indicator
|
||||
│
|
||||
├── lib/ # Shared code (imported by both frontend and backend)
|
||||
│ ├── types.ts # Core DayData interface
|
||||
│ ├── env.ts # getTodayLocal, isWithinOperatingWindow, getOperatingStatus
|
||||
│ ├── parks.ts # All 24 park definitions, PARK_MAP, groupByRegion
|
||||
│ ├── coaster-data.ts # Static RCDB coaster name sets per park
|
||||
│ ├── coaster-match.ts # Fuzzy name matching (normalize, prefix, compact)
|
||||
│ ├── queue-times-map.ts # Park ID -> Queue-Times.com park ID mapping
|
||||
│ ├── api.ts # apiFetch() helper (revalidate vs. no-store option)
|
||||
│ ├── outage.ts # computeOutages() — contiguous-closed-run detection
|
||||
│ ├── ride-slug.ts # slugifyRideName() — URL slug for ride pages
|
||||
│ ├── timezone.ts # formatLocalDate / formatLocalTime in a park's tz
|
||||
│ └── scrapers/
|
||||
│ ├── sixflags.ts # Six Flags CloudFront operating-hours client
|
||||
│ ├── sixflags-waittimes.ts # Six Flags Fast Lane wait-times client
|
||||
│ ├── queuetimes.ts # Queue-Times.com API client
|
||||
│ ├── log.ts # Shared scraper logger
|
||||
│ └── types.ts # Park, DayStatus, MonthCalendar, ScraperAdapter interfaces
|
||||
│
|
||||
├── backend/ # Hono API server (separate package.json)
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # Entry point: middleware, routes, DB init, scheduler start, graceful shutdown
|
||||
│ │ ├── config.ts # Env-validated config object (fails fast on bad input)
|
||||
│ │ ├── log.ts # Structured logger (`[ISO] [LEVEL] [tag] msg key=value`)
|
||||
│ │ ├── db/
|
||||
│ │ │ ├── index.ts # SQLite connection, schema for park_days / rides / ride_wait_samples, WAL mode
|
||||
│ │ │ └── queries.ts # All SQL queries (upsert, date range, staleness, samples, aggregates)
|
||||
│ │ ├── middleware/
|
||||
│ │ │ └── rate-limit.ts # Fixed-window per-IP limiter (honours x-forwarded-for)
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── calendar.ts # /api/calendar/* -- week and month data with live merging
|
||||
│ │ │ ├── parks.ts # /api/parks/* -- park metadata
|
||||
│ │ │ ├── rides.ts # /api/parks/:id/rides -- live rides + Fast Lane + schedule fallback
|
||||
│ │ │ ├── ride-history.ts # /api/parks/:id/rides/:slug -- ride detail + today/7d/30d history
|
||||
│ │ │ ├── status.ts # /api/status -- health check
|
||||
│ │ │ └── scrape.ts # /api/scrape/trigger -- manual scrape
|
||||
│ │ └── services/
|
||||
│ │ ├── scheduler.ts # Five-tier cron jobs with per-tier concurrency latches
|
||||
│ │ ├── scraper.ts # Scraping orchestration (today, month, full year)
|
||||
│ │ ├── wait-sampler.ts # Tier-5: 5-min wait-time sampling into ride_wait_samples
|
||||
│ │ ├── live-cache.ts # Shared TtlCaches (liveRidesCache, fastLaneCache, todayCache)
|
||||
│ │ └── cache.ts # Generic TtlCache<T> class
|
||||
│ ├── tests/ # Backend Node test runner suite
|
||||
│ ├── data/ # SQLite database (parks.db, auto-created)
|
||||
│ ├── package.json # Backend dependencies
|
||||
│ └── tsconfig.json # Backend TypeScript config (CommonJS, rootDir: ..)
|
||||
│
|
||||
├── tests/ # Frontend unit tests (Node built-in test runner)
|
||||
├── scripts/ # Debug utility
|
||||
├── public/ # Static assets
|
||||
├── Dockerfile # Multi-stage build (web + backend targets)
|
||||
├── docker-compose.yml # Production orchestration
|
||||
├── package.json # Frontend dependencies
|
||||
├── tsconfig.json # Frontend TypeScript config
|
||||
├── next.config.ts # Standalone output, CSP headers, security headers
|
||||
└── .gitea/workflows/deploy.yml # CI/CD pipeline
|
||||
```
|
||||
|
||||
The `lib/` directory is the key shared boundary -- it is imported by both the frontend (via `@/lib/*` path alias) and the backend (via `@lib/*` alias resolving to `../lib/*`). This avoids duplicating types and park definitions across packages.
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Scraping Pipeline
|
||||
|
||||
Data enters the system through the backend's cron-driven scraper:
|
||||
|
||||
```
|
||||
node-cron trigger
|
||||
|
|
||||
v
|
||||
scraper.ts (orchestration)
|
||||
|
|
||||
├── scrapeToday() scrapeMonths()
|
||||
| for each park: for each park × month:
|
||||
| fetchToday(apiId) scrapeMonth(apiId, year, month)
|
||||
| 500ms delay 1000ms delay
|
||||
| diff before write transaction-wrapped bulk upsert
|
||||
| staleness check (skip if fresh)
|
||||
v
|
||||
sixflags.ts (API client)
|
||||
|
|
||||
v
|
||||
Six Flags CloudFront API
|
||||
| GET /operating-hours/park/{apiId} (today, no date param)
|
||||
| GET /operating-hours/park/{apiId}?date=YYYYMM (full month)
|
||||
v
|
||||
parseApiDay() -> DayResult
|
||||
|
|
||||
v
|
||||
upsertDay() -> SQLite (park_days table)
|
||||
| INSERT ... ON CONFLICT DO UPDATE
|
||||
| WHERE park_days.date >= date('now') <-- past-date protection
|
||||
v
|
||||
Done
|
||||
```
|
||||
|
||||
**Key behaviors:**
|
||||
|
||||
- `scrapeToday()` uses a 500ms inter-park delay and diffs against the database before writing -- unchanged data is silently skipped.
|
||||
- `scrapeMonths()` uses a 1000ms delay, wraps each park-month's inserts in a SQLite transaction, and checks staleness before fetching. If a park-month was scraped within `PARK_HOURS_STALENESS_HOURS` (default 72h), it is skipped entirely.
|
||||
- The `WHERE date >= date('now')` clause in the upsert prevents overwriting historical data -- once a day passes, its record is frozen.
|
||||
- Rate limiting: on HTTP 429 or 503, the client retries with exponential backoff (30s, 60s, 120s). If a `Retry-After` header is present, it is respected (capped at 5 minutes). After 3 retries, a `RateLimitError` is thrown and logged; the scheduler continues to the next park.
|
||||
|
||||
### Request Pipeline
|
||||
|
||||
User-facing requests flow through Next.js server components to the backend API:
|
||||
|
||||
```
|
||||
Browser request
|
||||
|
|
||||
v
|
||||
Next.js Server Component (app/page.tsx or app/park/[id]/page.tsx)
|
||||
|
|
||||
| fetch(`${BACKEND_URL}/api/calendar/week?start=...`, { next: { revalidate: 120 } })
|
||||
v
|
||||
Hono route handler (backend/src/routes/calendar.ts)
|
||||
|
|
||||
| getDateRange(start, end) -- SQLite query
|
||||
| + optional live merging (see below)
|
||||
v
|
||||
JSON response -> React render -> HTML to browser
|
||||
```
|
||||
|
||||
**ISR revalidation values:**
|
||||
|
||||
| Page | Endpoint | Revalidate |
|
||||
|------|----------|------------|
|
||||
| Home (week view) | `/api/calendar/week` | 120s |
|
||||
| Park detail (month) | `/api/calendar/:parkId/month` | 300s |
|
||||
| Park detail (rides) | `/api/parks/:id/rides` | 60s |
|
||||
|
||||
### Live Data Merging
|
||||
|
||||
When the requested week includes today, the `/api/calendar/week` route enhances database data with live information:
|
||||
|
||||
1. **Live today hours** -- For each park, calls `fetchToday(apiId)` to get the current day's schedule directly from the Six Flags API. Results are cached in `todayCache` (5-min TTL). A `_checked` sentinel key prevents re-fetching parks that returned `null`.
|
||||
|
||||
2. **Live ride counts** -- For each park that is currently within its operating window (determined by `isWithinOperatingWindow()`), fetches live ride data from Queue-Times.com via `fetchLiveRides()`. Counts open rides and open coasters. Results cached in `ridesCache` (5-min TTL).
|
||||
|
||||
3. **Status detection:**
|
||||
- **Open**: Within the scheduled open-to-close window. `getOperatingStatus()` returns `"open"`.
|
||||
- **Closing**: Current time is past the scheduled close but within a 1-hour wind-down buffer. `getOperatingStatus()` returns `"closing"`.
|
||||
- **Weather delay**: `getOperatingStatus()` is `"open"` _and_ every reported ride has `isOpen: false`. Indicated with a blue badge. The badge is intentionally suppressed during the `"closing"` wind-down — all-rides-closed near close is normal end-of-day behavior, not weather. Logic lives at [backend/src/routes/rides.ts:96-100](../backend/src/routes/rides.ts) and [backend/src/routes/calendar.ts](../backend/src/routes/calendar.ts).
|
||||
|
||||
The 3 AM switchover in `getTodayLocal()` prevents the calendar from flipping to the next day at midnight -- before 3 AM local time, the system still considers it "yesterday", since park visitors may still be out.
|
||||
|
||||
---
|
||||
|
||||
## Caching Architecture
|
||||
|
||||
The system uses three layers of caching, each serving a different purpose:
|
||||
|
||||
```
|
||||
Layer 1: Next.js ISR Layer 2: Backend In-Memory Layer 3: Database Staleness
|
||||
(serves stale while revalidating) (prevents redundant API calls) (controls scrape frequency)
|
||||
┌───────────────────────────────┐ ┌───────────────────────────────┐ ┌───────────────────────────────┐
|
||||
│ Cache-Control response headers│ │ TtlCache<T> (5 min TTL) │ │ isMonthScraped() query │
|
||||
│ + Next.js fetch revalidate │ │ │ │ MAX(scraped_at) vs staleness │
|
||||
│ │ │ todayCache: routes/calendar│ │ threshold (default 72h) │
|
||||
│ week: 120s / 300s SWR │ │ (live park hours per park) │ │ │
|
||||
│ month: 300s / 600s SWR │ │ liveRidesCache, fastLaneCache:│ │ Past months auto-skipped │
|
||||
│ rides: 60s / 120s SWR │ │ services/live-cache.ts — │ │ "force" scope bypasses check │
|
||||
│ parks: 3600s │ │ shared by rides routes + │ │ │
|
||||
│ │ │ the Tier-5 sampler │ │ │
|
||||
└───────────────────────────────┘ └───────────────────────────────┘ └───────────────────────────────┘
|
||||
```
|
||||
|
||||
**Live-page Data Cache bypass.** The park detail page (`app/park/[id]/page.tsx`) and ride detail page (`app/park/[id]/ride/[slug]/page.tsx`) fetch their live ride data with `cache: "no-store"` via [`apiFetch`](../lib/api.ts) (`{ noStore: true }`). Earlier revisions used Next.js ISR for these too, but the Data Cache served stale ride state after idle periods — navigation back to a park would show ride statuses from hours ago. Backend HTTP cache headers still allow the upstream Hono server to return cached responses for 60s, so this is a "skip the Next.js Data Cache" change, not a "skip all caching" change. The home calendar page keeps its ISR revalidation since its data is intrinsically slower-moving.
|
||||
|
||||
**Per-route HTTP cache headers:**
|
||||
|
||||
| Endpoint | `max-age` | `stale-while-revalidate` |
|
||||
|----------|-----------|--------------------------|
|
||||
| `/api/calendar/week` | 120s | 300s |
|
||||
| `/api/calendar/:parkId/month` | 300s | 600s |
|
||||
| `/api/parks` | 3600s | -- |
|
||||
| `/api/parks/:id` | 3600s | -- |
|
||||
| `/api/parks/:id/rides` | 60s | 120s |
|
||||
| `/api/parks/:id/rides/:slug` | 60s | 120s |
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
### Schema
|
||||
|
||||
The database has three tables: `park_days` (calendar hours), `rides` (per-ride metadata), and `ride_wait_samples` (time-series wait data).
|
||||
|
||||
```sql
|
||||
-- Park operating hours, keyed by park and date.
|
||||
CREATE TABLE IF NOT EXISTS park_days (
|
||||
park_id TEXT NOT NULL, -- matches Park.id from lib/parks.ts (e.g. "cedarpoint")
|
||||
date TEXT NOT NULL, -- ISO date: YYYY-MM-DD
|
||||
is_open INTEGER NOT NULL DEFAULT 0, -- 0 = closed, 1 = open
|
||||
hours_label TEXT, -- e.g. "10am - 6pm", null when closed
|
||||
special_type TEXT, -- "passholder_preview" or null
|
||||
scraped_at TEXT NOT NULL, -- ISO timestamp of when this row was written
|
||||
PRIMARY KEY (park_id, date)
|
||||
);
|
||||
|
||||
-- Per-ride canonical record. PK is (park_id, qt_ride_id) so ride renames
|
||||
-- don't fragment history — the slug just provides pretty URLs.
|
||||
CREATE TABLE IF NOT EXISTS rides (
|
||||
park_id TEXT NOT NULL,
|
||||
qt_ride_id INTEGER NOT NULL, -- Queue-Times ride ID (stable upstream)
|
||||
slug TEXT NOT NULL, -- URL slug (rebuilt if name changes)
|
||||
name TEXT NOT NULL, -- Display name as last seen
|
||||
is_coaster INTEGER NOT NULL DEFAULT 0,
|
||||
has_fast_lane INTEGER NOT NULL DEFAULT 0,
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
PRIMARY KEY (park_id, qt_ride_id)
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_rides_slug ON rides (park_id, slug);
|
||||
|
||||
-- Time-series wait samples written by Tier-5 every 5 minutes for currently
|
||||
-- open parks. `recorded_at` is UTC; `local_date` / `local_time` are bucketed
|
||||
-- in the park's IANA timezone at insert time so reads are pure SQL and DST-safe.
|
||||
CREATE TABLE IF NOT EXISTS ride_wait_samples (
|
||||
park_id TEXT NOT NULL,
|
||||
qt_ride_id INTEGER NOT NULL,
|
||||
recorded_at TEXT NOT NULL, -- ISO UTC
|
||||
local_date TEXT NOT NULL, -- YYYY-MM-DD in park tz
|
||||
local_time TEXT NOT NULL, -- HH:MM in park tz
|
||||
is_open INTEGER NOT NULL,
|
||||
wait_minutes INTEGER, -- Regular line wait
|
||||
fast_lane_minutes INTEGER, -- Six Flags Fast Lane wait, if known
|
||||
PRIMARY KEY (park_id, qt_ride_id, recorded_at)
|
||||
);
|
||||
```
|
||||
|
||||
- **Composite primary keys** ensure one row per logical unit (per park-day, per ride, per sample) and support efficient queries without secondary indexes. `idx_rides_slug` lets the ride detail route resolve a `slug` to a `qt_ride_id` in one lookup.
|
||||
- **WAL mode** (`PRAGMA journal_mode = WAL`) enables concurrent reads while the scraper writes.
|
||||
- **Migration strategy**: New columns are added via `ALTER TABLE ... ADD COLUMN` wrapped in try/catch. If the column already exists, the error is silently caught. This allows the schema to evolve without a migration framework.
|
||||
- **Sample volume**: Tier-5 writes one row per open ride every 5 minutes during park hours. A park with 50 rides operating for 10 hours generates ~6,000 sample rows/day. `INSERT OR IGNORE` on the PK makes the sampler idempotent across retries.
|
||||
|
||||
### Key Queries
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `upsertDay()` | Insert or update a day. Uses `ON CONFLICT DO UPDATE` with `WHERE date >= date('now')` to protect historical records. |
|
||||
| `getDateRange(start, end)` | Returns all parks' data for a date range. Powers the week calendar. |
|
||||
| `getParkMonthData(parkId, year, month)` | Returns one park's data for a month. Uses `LIKE` prefix matching on date. |
|
||||
| `getDayData(parkId, date)` | Returns a single day for comparison during `scrapeToday()`. |
|
||||
| `getParkDayCount()` | Total rows in `park_days`. Drives the startup-scrape-when-empty check. |
|
||||
| `isMonthScraped(parkId, year, month, staleAfterMs)` | Checks if `MAX(scraped_at)` for a park-month is within the staleness threshold. Past months always return `true` (never re-scraped). |
|
||||
| `upsertRide()` | Insert or update a row in `rides`; bumps `last_seen` on every observation. |
|
||||
| `getRideBySlug(parkId, slug)` | Resolves a URL slug back to a canonical ride record via `idx_rides_slug`. |
|
||||
| `insertSample()` | `INSERT OR IGNORE` a sample into `ride_wait_samples` — idempotent on retries. |
|
||||
| `getRideSamplesForDay()` | Returns all samples for one ride on one local date (powers the Today chart). |
|
||||
| `getRideDailyAggregates()` | Per-day avg/max wait, avg/max Fast Lane, uptime %, and sample count over a window (powers the 7d / 30d charts). |
|
||||
| `countRideDays()` | Number of distinct `local_date` values for a ride in a window — used to decide whether 7d/30d tabs have enough data to render. |
|
||||
| `transact(fn)` | Wraps a function in a SQLite transaction for atomicity. |
|
||||
|
||||
### Storage
|
||||
|
||||
- **Location**: `backend/data/parks.db` (or `/app/backend/data/parks.db` in Docker)
|
||||
- **WAL journal files**: `parks.db-wal` and `parks.db-shm` accompany the main database
|
||||
- **Size**:
|
||||
- `park_days`: ~8,000-9,000 rows for a full year of 24 parks
|
||||
- `rides`: ~1,000-1,500 rows total (a few dozen per park)
|
||||
- `ride_wait_samples`: grows daily during operating season; expect tens of thousands of rows per active day. Historical samples are retained — no automatic pruning is configured.
|
||||
- **Not committed to git**: Listed in `.gitignore`
|
||||
- **Auto-created**: The database and `data/` directory are created on first backend startup
|
||||
|
||||
---
|
||||
|
||||
## External API Contracts
|
||||
|
||||
### Six Flags CloudFront API
|
||||
|
||||
The primary data source for park operating hours and ride schedules.
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Base URL | `https://d18car1k0ff81h.cloudfront.net/operating-hours/park/{apiId}` |
|
||||
| Auth | None (public, but uses spoofed browser headers) |
|
||||
| Timeout | 15 seconds (`AbortSignal.timeout`) |
|
||||
| Rate limiting | 429/503 with exponential backoff |
|
||||
|
||||
**Two call patterns:**
|
||||
|
||||
1. **Today** (no date param): `GET /operating-hours/park/{apiId}` -- returns a single day.
|
||||
2. **Full month**: `GET /operating-hours/park/{apiId}?date=YYYYMM` -- returns all days in the month.
|
||||
|
||||
**Response shape:**
|
||||
|
||||
```typescript
|
||||
interface ApiResponse {
|
||||
parkId: number;
|
||||
parkAbbreviation: string;
|
||||
parkName: string;
|
||||
dates: ApiDay[];
|
||||
}
|
||||
|
||||
interface ApiDay {
|
||||
date: string; // "MM/DD/YYYY"
|
||||
isParkClosed: boolean;
|
||||
events?: ApiEvent[]; // passholder previews, special events
|
||||
operatings?: ApiOperating[]; // operating hours by type ("Park", "Special Event")
|
||||
venues?: ApiVenue[]; // ride-level detail hours
|
||||
}
|
||||
```
|
||||
|
||||
**Parsing logic (`parseApiDay`):**
|
||||
- Finds the "Park" operating type (falls back to first available)
|
||||
- Extracts `timeFrom`/`timeTo` from the first item and formats to 12-hour (`"10am - 6pm"`)
|
||||
- Detects passholder previews via `events[].extEventName` containing "passholder preview"
|
||||
- Handles buyouts: if `isBuyout` is true and it's not a passholder preview, the park is considered closed
|
||||
- Returns `{ date, isOpen, hoursLabel, specialType }`
|
||||
|
||||
### Six Flags Wait-Times API
|
||||
|
||||
Powers the Fast Lane wait number shown alongside the regular wait. Used by `lib/scrapers/sixflags-waittimes.ts` (`fetchFastLaneWaits`, `lookupFastLane`) and joined onto the Queue-Times rides by fuzzy name match.
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| URL | `https://d18car1k0ff81h.cloudfront.net/wait-times/park/{apiId}` (sibling of the operating-hours endpoint) |
|
||||
| Auth | None (spoofed browser headers, same as the operating-hours client) |
|
||||
| Timeout | 10 seconds |
|
||||
| Per-ride fields | `regularMinutes`, `fastLaneMinutes`, `hasFastLane` (`lookupFastLane()` return shape) |
|
||||
| Error handling | Returns `null` on any failure; the route falls back to Queue-Times' regular wait |
|
||||
| Backend cache | `fastLaneCache` (5-min TTL, in `services/live-cache.ts`) |
|
||||
|
||||
**Why two sources?** Queue-Times wait values lag at park open by ~10-15 minutes (parks haven't reported yet). The Six Flags wait-times feed updates earlier. When both sources have a wait for the same ride, the route prefers the Six Flags regular wait; Queue-Times remains the source of truth for `isOpen`. The Fast Lane number has no Queue-Times equivalent.
|
||||
|
||||
### Queue-Times.com API
|
||||
|
||||
Provides live ride open/closed status and wait times during park operating hours.
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| URL | `https://queue-times.com/parks/{queueTimesId}/queue_times.json` |
|
||||
| Auth | None (public) |
|
||||
| Timeout | 10 seconds (`AbortSignal.timeout`) |
|
||||
| Update frequency | ~5 minutes during park operation |
|
||||
| Attribution | Required: "Powered by Queue-Times.com" |
|
||||
| Error handling | Returns `null` on any failure (no exceptions) |
|
||||
|
||||
**Response shape:**
|
||||
|
||||
```typescript
|
||||
interface QTResponse {
|
||||
lands: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
rides: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
is_open: boolean;
|
||||
wait_time: number;
|
||||
last_updated: string; // ISO 8601
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
### Coaster Classification
|
||||
|
||||
Rides are classified as roller coasters using static data from the Roller Coaster Database (RCDB), stored in `lib/coaster-data.ts` as `Set<string>` per park. Matching uses three strategies (in order):
|
||||
|
||||
1. **Exact normalized match** -- both names are lowercased, stripped of trademark symbols, possessives, and leading "THE".
|
||||
2. **Compact match** -- spaces are removed from both names (catches "BAT GIRL" vs "Batgirl").
|
||||
3. **Prefix match** -- the shorter name is a prefix of the longer (min 5 chars), unless the next word after the prefix is a conjunction ("y", "and", "&"), which signals a different ride rather than a subtitle.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
### Server vs Client Components
|
||||
|
||||
| Component | Type | Role |
|
||||
|-----------|------|------|
|
||||
| `app/page.tsx` | Server | Fetches week data from backend, passes to HomePageClient |
|
||||
| `app/park/[id]/page.tsx` | Server | Fetches month + rides data in parallel |
|
||||
| `app/park/[id]/ride/[slug]/page.tsx` | Server | Fetches ride detail + today/7d/30d history in one call |
|
||||
| `HomePageClient` | **Client** | State management, auto-refresh, keyboard nav, localStorage |
|
||||
| `WeekCalendar` | Server | Desktop 7-column table layout |
|
||||
| `MobileCardList` | Server | Mobile card layout |
|
||||
| `ParkCard` | Server | Individual park card for mobile |
|
||||
| `ParkMonthCalendar` | Server | Month calendar grid |
|
||||
| `LiveRidePanel` | **Client** | Live ride list with coaster filter + Fast Lane toggle |
|
||||
| `WeekNav` | **Client** | Week navigation with arrow buttons |
|
||||
| `Legend` | Server | Status color legend |
|
||||
| `EmptyState` | Server | Empty database message |
|
||||
| `BackToCalendarLink` | **Client** | "Back" link using localStorage for last week |
|
||||
| `charts/WaitTimeTodayChart` | **Client** | Today's 5-min wait samples + outage shading (Recharts) |
|
||||
| `charts/WeeklyStatsChart` | **Client** | 7d / 30d daily aggregates chart (Recharts) |
|
||||
| `charts/UptimePill` | **Client** | Compact uptime % badge |
|
||||
|
||||
### Component Hierarchy
|
||||
|
||||
```
|
||||
page.tsx (Server)
|
||||
└── HomePageClient (Client)
|
||||
├── WeekNav (Client) ............ arrow buttons, keyboard listener
|
||||
├── Legend (Server) ............. color key
|
||||
├── MobileCardList (Server) ..... visible below lg breakpoint
|
||||
│ └── ParkCard (Server)
|
||||
└── WeekCalendar (Server) ....... visible at lg+ breakpoint
|
||||
|
||||
park/[id]/page.tsx (Server)
|
||||
├── BackToCalendarLink (Client)
|
||||
├── ParkMonthCalendar (Server)
|
||||
└── LiveRidePanel (Client) ........... or RideList (Server, inline)
|
||||
|
||||
park/[id]/ride/[slug]/page.tsx (Server)
|
||||
├── BackToCalendarLink (Client)
|
||||
├── UptimePill (Client)
|
||||
├── WaitTimeTodayChart (Client) ...... Today tab
|
||||
└── WeeklyStatsChart (Client) ........ 7d / 30d tabs
|
||||
```
|
||||
|
||||
### Client-Side Refresh
|
||||
|
||||
`HomePageClient` manages three refresh mechanisms when viewing the current week:
|
||||
|
||||
1. **Periodic refresh**: `setInterval(router.refresh, 120_000)` -- re-fetches data every 2 minutes via Next.js router refresh (no full page reload).
|
||||
2. **Opening-time refresh**: For each park open today, calculates milliseconds until its opening time using `msUntilLocalTime()` (timezone-aware). Schedules `router.refresh()` at opening and again 30 seconds later (to pick up ride counts after Queue-Times starts reporting).
|
||||
3. **Keyboard navigation**: Left/right arrow keys navigate between weeks (via `WeekNav` component).
|
||||
|
||||
### Cookies
|
||||
|
||||
| Name | Purpose |
|
||||
|------|---------|
|
||||
| `tcWeek` | Selected week start date (`YYYY-MM-DD`). Set by `WeekNav` and read server-side by `app/page.tsx`, so the home page renders the right week without polluting the URL. |
|
||||
|
||||
### localStorage
|
||||
|
||||
| Key | Purpose |
|
||||
|-----|---------|
|
||||
| `coasterMode` | Persists the "Coasters only" toggle state across sessions |
|
||||
| `fastLaneMode` | Persists the Fast Lane wait toggle on the park page |
|
||||
|
||||
### Responsive Design
|
||||
|
||||
The `lg:` Tailwind breakpoint (1024px) switches between two layouts:
|
||||
- **Below `lg`**: `MobileCardList` -- parks shown as cards with daily status indicators
|
||||
- **At `lg`+**: `WeekCalendar` -- full 7-column table with region groupings
|
||||
|
||||
### Loading State
|
||||
|
||||
`app/loading.tsx` renders a skeleton UI with a CSS pulse animation, providing immediate visual feedback while server components stream.
|
||||
|
||||
---
|
||||
|
||||
## Park Data Model
|
||||
|
||||
All 24 parks are defined in `lib/parks.ts` as a `PARKS` array with the following interface:
|
||||
|
||||
```typescript
|
||||
interface Park {
|
||||
id: string; // Unique identifier (e.g. "cedarpoint", "greatadventure")
|
||||
apiId: number; // Six Flags CloudFront API park ID
|
||||
name: string; // Full display name
|
||||
shortName: string; // Abbreviated name for logs and compact UI
|
||||
chain: string; // "sixflags" for all parks
|
||||
slug: string; // URL-safe slug (matches sixflags.com paths)
|
||||
region: string; // One of 5 geographic regions
|
||||
location: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
city: string;
|
||||
state: string;
|
||||
};
|
||||
timezone: string; // IANA timezone (e.g. "America/New_York")
|
||||
website: string; // Park website URL
|
||||
}
|
||||
```
|
||||
|
||||
**Regions:**
|
||||
- Northeast (6 parks): Great Adventure, New England, Great Escape, Darien Lake, Dorney Park, Canada's Wonderland
|
||||
- Southeast (3 parks): Over Georgia, Carowinds, Kings Dominion
|
||||
- Midwest (7 parks): Great America (IL), St. Louis, Cedar Point, Kings Island, Valleyfair, Worlds of Fun, Michigan's Adventure
|
||||
- Texas & South (3 parks): Over Texas, Fiesta Texas, Frontier City
|
||||
- West & International (5 parks): Magic Mountain, Discovery Kingdom, Knott's Berry Farm, California's Great America, Mexico
|
||||
|
||||
**Lookup utilities:**
|
||||
- `PARK_MAP`: `Map<string, Park>` for O(1) lookup by `id`
|
||||
- `groupByRegion(parks)`: Groups a park array into `{ region, parks }` tuples
|
||||
- `QUEUE_TIMES_IDS`: Maps park `id` to Queue-Times.com park ID (separate file)
|
||||
- `getCoasterSet(parkId)`: Returns the `Set<string>` of normalized coaster names for a park
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
| Measure | Implementation |
|
||||
|---------|----------------|
|
||||
| Content Security Policy | Defined in `next.config.ts` -- restricts scripts, styles, images, connections |
|
||||
| X-Frame-Options | `DENY` -- prevents embedding in iframes |
|
||||
| X-Content-Type-Options | `nosniff` -- prevents MIME type sniffing |
|
||||
| Referrer-Policy | `strict-origin-when-cross-origin` |
|
||||
| Permissions-Policy | Disables geolocation, microphone, camera |
|
||||
| Non-root containers | Both Docker images run as `nextjs` user (UID 1001) |
|
||||
| Backend-owned data | Frontend never contacts external APIs or the database directly |
|
||||
| CORS | Backend enables CORS middleware (currently unrestricted) |
|
||||
| Per-IP rate limit | `RATE_LIMIT_PER_MIN` (default 60) — fixed-window per-IP counter in `backend/src/middleware/rate-limit.ts`. Honours `x-forwarded-for`/`x-real-ip` so a reverse proxy doesn't collapse every client to one bucket. Over-limit requests return `429` with a `Retry-After` header. |
|
||||
| Env validation | `backend/src/config.ts` parses + validates env vars at startup; misconfiguration fails fast rather than surfacing in a request handler. |
|
||||
| Graceful shutdown | Backend listens for `SIGTERM`/`SIGINT`, closes the HTTP server and SQLite handle before exiting (force-exit timeout as a safety net). |
|
||||
| No secrets in frontend | `BACKEND_URL` is an internal Docker network address, not a secret |
|
||||
@@ -0,0 +1,295 @@
|
||||
# Development
|
||||
|
||||
> See also: [Architecture](ARCHITECTURE.md) | [Operations](OPERATIONS.md) | [API Reference](API.md)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js 22+** and **npm**
|
||||
- No database tools needed (SQLite is auto-created by the backend)
|
||||
- No Docker needed for local development
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <repo-url>
|
||||
cd ThoosieCalendar
|
||||
|
||||
# Install frontend dependencies
|
||||
npm install
|
||||
|
||||
# Install backend dependencies
|
||||
cd backend && npm install && cd ..
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running Locally
|
||||
|
||||
The project requires two terminals -- one for the backend, one for the frontend.
|
||||
|
||||
### Terminal 1: Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This starts the Hono API server on port 3001 using `tsx` (TypeScript runtime with watch mode). On first run:
|
||||
- Creates an empty SQLite database at `backend/data/parks.db`
|
||||
- Registers the four-tier cron scheduler
|
||||
- The schedulers will populate data automatically over time, or you can trigger a manual scrape immediately:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/scrape/trigger?scope=full
|
||||
```
|
||||
|
||||
### Terminal 2: Frontend
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This starts the Next.js dev server on port 3000 with hot reload. Open [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
**Navigation:**
|
||||
- Use the `←` / `→` buttons (or arrow keys) to navigate weeks; the selected week persists across visits via the `tcWeek` cookie
|
||||
- Click any park name to open its detail page with month calendar and ride status
|
||||
|
||||
---
|
||||
|
||||
## Project Structure Walkthrough
|
||||
|
||||
### `app/` -- Next.js Pages
|
||||
|
||||
Three routes:
|
||||
- `/` (`app/page.tsx`) -- Home page. Server component that fetches week data from the backend and passes everything to `HomePageClient`.
|
||||
- `/park/[id]` (`app/park/[id]/page.tsx`) -- Park detail page. Fetches month calendar and live rides in parallel via `Promise.all`. Live rides use `apiFetch({ noStore: true })` to bypass the Next.js Data Cache.
|
||||
- `/park/[id]/ride/[slug]` (`app/park/[id]/ride/[slug]/page.tsx`) -- Per-ride detail page with Today / 7d / 30d wait-time history. All three tabs render from a single backend response (no client-side range fetches).
|
||||
|
||||
Top-level boundaries: `app/error.tsx` (root error UI), `app/not-found.tsx`, `app/park/[id]/error.tsx`, and `app/loading.tsx` (streaming skeleton).
|
||||
|
||||
### `components/` -- React Components
|
||||
|
||||
| Component | Type | Purpose |
|
||||
|-----------|------|---------|
|
||||
| `HomePageClient` | Client | Top-level state: coaster filter, auto-refresh, keyboard nav |
|
||||
| `WeekCalendar` | Server | Desktop 7-column table with region groupings |
|
||||
| `MobileCardList` | Server | Mobile card layout (below `lg` breakpoint) |
|
||||
| `ParkCard` | Server | Individual park card for mobile |
|
||||
| `ParkMonthCalendar` | Server | Month grid for park detail page |
|
||||
| `LiveRidePanel` | Client | Live ride list with coaster toggle, Fast Lane toggle, wait times |
|
||||
| `WeekNav` | Client | Week navigation arrows |
|
||||
| `Legend` | Server | Color legend for status indicators |
|
||||
| `EmptyState` | Server | Empty database message |
|
||||
| `BackToCalendarLink` | Client | Back link using localStorage for last week |
|
||||
| `charts/WaitTimeTodayChart` | Client | Today's 5-min wait samples with outage shading (Recharts) |
|
||||
| `charts/WeeklyStatsChart` | Client | 7d / 30d daily aggregate chart (Recharts) |
|
||||
| `charts/UptimePill` | Client | Compact uptime % badge |
|
||||
|
||||
### `lib/` -- Shared Code
|
||||
|
||||
Imported by both frontend and backend:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `types.ts` | Core `DayData` interface |
|
||||
| `env.ts` | `getTodayLocal()` (3 AM switchover), `isWithinOperatingWindow()`, `getOperatingStatus()`, `parseStalenessHours()` |
|
||||
| `parks.ts` | All 24 park definitions, `PARK_MAP`, `groupByRegion()` |
|
||||
| `coaster-data.ts` | Static RCDB coaster name sets per park, `getCoasterSet()` |
|
||||
| `coaster-match.ts` | `normalizeForMatch()`, `isCoasterMatch()` -- fuzzy name matching |
|
||||
| `queue-times-map.ts` | `QUEUE_TIMES_IDS` -- park ID to Queue-Times park ID mapping |
|
||||
| `api.ts` | `apiFetch<T>()` -- typed fetch helper with `revalidate` or `noStore` option |
|
||||
| `outage.ts` | `computeOutages()` -- detects contiguous closed-during-hours runs for the today chart |
|
||||
| `ride-slug.ts` | `slugifyRideName()` -- URL slug used by `/park/[id]/ride/[slug]` and the `rides` table |
|
||||
| `timezone.ts` | `formatLocalDate()`, `formatLocalTime()` for bucketing samples in a park's IANA tz |
|
||||
| `scrapers/sixflags.ts` | Six Flags CloudFront operating-hours client -- `scrapeMonth()`, `fetchToday()`, `scrapeRidesForDay()`, rate limiting |
|
||||
| `scrapers/sixflags-waittimes.ts` | Six Flags Fast Lane wait-times client -- `fetchFastLaneWaits()`, `lookupFastLane()` |
|
||||
| `scrapers/queuetimes.ts` | Queue-Times.com API client -- `fetchLiveRides()` |
|
||||
| `scrapers/log.ts` | Shared scraper logger (used by both `sixflags.ts` and `sixflags-waittimes.ts`) |
|
||||
| `scrapers/types.ts` | `Park`, `DayStatus`, `MonthCalendar`, `ScraperAdapter` interfaces |
|
||||
|
||||
### `backend/src/` -- Hono API Server
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `index.ts` | Entry point -- middleware (request log, CORS, rate limit), route registration, DB init, scheduler start, graceful shutdown |
|
||||
| `config.ts` | Env-validated config object (`PORT`, `RATE_LIMIT_PER_MIN`, `PARK_HOURS_STALENESS_HOURS`, `NODE_ENV`). Fails fast on bad input. |
|
||||
| `log.ts` | Structured logger -- emits `[ISO] [LEVEL] [tag] msg key=value` lines. No external dep. |
|
||||
| `db/index.ts` | SQLite connection singleton, schema for `park_days` / `rides` / `ride_wait_samples`, WAL mode |
|
||||
| `db/queries.ts` | All SQL queries -- `upsertDay`, `getDateRange`, `isMonthScraped`, `upsertRide`, `getRideBySlug`, `insertSample`, `getRideSamplesForDay`, `getRideDailyAggregates`, `countRideDays`, `getParkDayCount`, `transact` |
|
||||
| `middleware/rate-limit.ts` | Fixed-window per-IP limiter. Honours `x-forwarded-for` / `x-real-ip`. Returns 429 with `Retry-After`. |
|
||||
| `routes/calendar.ts` | `/api/calendar/*` -- week and month data with live today merging |
|
||||
| `routes/parks.ts` | `/api/parks/*` -- park metadata |
|
||||
| `routes/rides.ts` | `/api/parks/:id/rides` -- live ride status + Fast Lane join + schedule fallback |
|
||||
| `routes/ride-history.ts` | `/api/parks/:id/rides/:slug` -- ride detail + today/7d/30d history in one payload |
|
||||
| `routes/status.ts` | `/api/status` -- health check |
|
||||
| `routes/scrape.ts` | `/api/scrape/trigger` -- manual scrape |
|
||||
| `services/scheduler.ts` | Five-tier cron registration with per-tier `withLatch` concurrency guards; startup-scrape-when-empty check |
|
||||
| `services/scraper.ts` | Scraping orchestration -- `scrapeToday()`, `scrapeMonths()`, `scrapeFullYear()` |
|
||||
| `services/wait-sampler.ts` | Tier-5 5-minute sampler -- joins Queue-Times + Fast Lane, writes `ride_wait_samples`, skips weather-delayed parks |
|
||||
| `services/live-cache.ts` | Shared `TtlCache<T>` instances (`liveRidesCache`, `fastLaneCache`) so the rides route, the ride-history route, and the Tier-5 sampler share warmed upstream data |
|
||||
| `services/cache.ts` | Generic `TtlCache<T>` class with configurable TTL |
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Park
|
||||
|
||||
Adding a park requires changes to three files. The park will be automatically picked up by the scheduler, the API, and the frontend.
|
||||
|
||||
### 1. `lib/parks.ts`
|
||||
|
||||
Add an entry to the `PARKS` array:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: "newpark", // URL-safe unique identifier
|
||||
apiId: 123, // Six Flags CloudFront API park ID
|
||||
name: "Six Flags New Park",
|
||||
shortName: "New Park",
|
||||
chain: "sixflags",
|
||||
slug: "newpark", // Should match sixflags.com URL path
|
||||
region: "Midwest", // One of: Northeast, Southeast, Midwest, Texas & South, West & International
|
||||
location: {
|
||||
lat: 40.0,
|
||||
lng: -80.0,
|
||||
city: "Anytown",
|
||||
state: "OH",
|
||||
},
|
||||
timezone: "America/New_York", // IANA timezone
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
```
|
||||
|
||||
**Finding the API ID:** Use the debug script or inspect network requests on the Six Flags website. The `apiId` is the numeric park identifier in the CloudFront API URL.
|
||||
|
||||
### 2. `lib/queue-times-map.ts`
|
||||
|
||||
Add the Queue-Times.com park ID mapping:
|
||||
|
||||
```typescript
|
||||
export const QUEUE_TIMES_IDS: Record<string, number> = {
|
||||
// ... existing mappings
|
||||
newpark: 456, // Queue-Times park ID
|
||||
};
|
||||
```
|
||||
|
||||
**Finding the Queue-Times ID:** Browse [queue-times.com](https://queue-times.com), navigate to the park, and note the numeric ID in the URL.
|
||||
|
||||
### 3. `lib/coaster-data.ts`
|
||||
|
||||
Add the coaster name set:
|
||||
|
||||
```typescript
|
||||
export function getCoasterSet(parkId: string): Set<string> | null {
|
||||
// ... existing cases
|
||||
case "newpark":
|
||||
return new Set([
|
||||
normalizeForMatch("Coaster Name One"),
|
||||
normalizeForMatch("Coaster Name Two"),
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
**Finding coaster names:** Look up the park on [RCDB (Roller Coaster Database)](https://rcdb.com). List all operating roller coasters. Names should be the official RCDB names before normalization.
|
||||
|
||||
---
|
||||
|
||||
## Debug Script
|
||||
|
||||
Inspect raw API data and parsed output for any park and date:
|
||||
|
||||
```bash
|
||||
npm run debug -- --park <parkId> --date <YYYY-MM-DD>
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
npm run debug -- --park kingsisland --date 2026-06-15
|
||||
```
|
||||
|
||||
This fetches the raw Six Flags API response for the park and date, displays the parsed result, and saves the raw JSON to the `debug/` directory for inspection. Useful for:
|
||||
- Investigating API response format changes
|
||||
- Debugging parsing issues for specific parks/dates
|
||||
- Verifying that a park's `apiId` is correct
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Frontend and backend each have their own test suite, both using the Node built-in test runner.
|
||||
|
||||
### Frontend tests
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Test files live in `tests/`:
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `tests/coaster-matching.test.ts` | `isCoasterMatch()` — exact, prefix, compact, conjunction rejection |
|
||||
| `tests/fast-lane-matching.test.ts` | `lookupFastLane()` — name normalization and Fast Lane join logic |
|
||||
| `tests/outage-detection.test.ts` | `computeOutages()` — contiguous-closed-run detection for the today chart |
|
||||
| `tests/ride-slug.test.ts` | `slugifyRideName()` — URL slug generation and stability |
|
||||
| `tests/timezone-bucketing.test.ts` | `formatLocalDate()` / `formatLocalTime()` — DST-safe park-tz bucketing |
|
||||
|
||||
### Backend tests
|
||||
|
||||
```bash
|
||||
cd backend && npm test
|
||||
```
|
||||
|
||||
Test files live in `backend/tests/`:
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `backend/tests/wait-aggregation.test.ts` | SQL aggregation in `getRideDailyAggregates()` — averages, max, uptime, sample count |
|
||||
|
||||
---
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### TypeScript
|
||||
|
||||
- **Strict mode** enabled in both `tsconfig.json` files
|
||||
- Frontend uses `bundler` module resolution with `@/*` path alias
|
||||
- Backend uses `CommonJS` modules with `@lib/*` alias resolving to `../lib/*`
|
||||
|
||||
### Styling
|
||||
|
||||
- **Inline styles** via `style={{}}` props for most component styling
|
||||
- **Tailwind CSS v4** for responsive utilities (`hidden lg:block`, `sm:flex`, `px-4 sm:px-6`)
|
||||
- Theme defined via `@theme {}` block and CSS custom properties in `app/globals.css`
|
||||
- No CSS modules, no styled-components, no component library
|
||||
|
||||
### Code Organization
|
||||
|
||||
- Shared types and utilities live in `lib/` and are imported by both frontend and backend
|
||||
- No component library -- all UI is built from scratch
|
||||
- Backend uses `tsx` for runtime TypeScript execution (no build step in development)
|
||||
|
||||
---
|
||||
|
||||
## Building Docker Images Locally
|
||||
|
||||
```bash
|
||||
# Build the web image
|
||||
docker build --target web -t thoosiecalendar:web .
|
||||
|
||||
# Build the backend image
|
||||
docker build --target backend -t thoosiecalendar:backend .
|
||||
|
||||
# Run locally with Docker Compose
|
||||
docker compose up -d
|
||||
|
||||
# Or run individual containers
|
||||
docker run -d -p 3001:3001 -v park_data:/app/backend/data -e TZ=America/New_York thoosiecalendar:backend
|
||||
docker run -d -p 3000:3000 -e BACKEND_URL=http://host.docker.internal:3001 thoosiecalendar:web
|
||||
```
|
||||
|
||||
When running individual containers outside of Docker Compose, use `host.docker.internal` instead of `backend` for the `BACKEND_URL`, since Docker's internal DNS won't resolve service names without Compose.
|
||||
@@ -0,0 +1,519 @@
|
||||
# Operations
|
||||
|
||||
> See also: [Architecture](ARCHITECTURE.md) | [API Reference](API.md) | [Development](DEVELOPMENT.md)
|
||||
|
||||
## Deployment Overview
|
||||
|
||||
The application runs as two Docker containers:
|
||||
|
||||
| Container | Port | Role |
|
||||
|-----------|------|------|
|
||||
| `web` | 3000 | Next.js frontend (stateless, no database) |
|
||||
| `backend` | 3001 | Hono API server (owns SQLite database, runs cron scheduler) |
|
||||
|
||||
**Infrastructure requirements:**
|
||||
- Docker host with Docker Compose
|
||||
- Container registry (Gitea, Docker Hub, or any OCI-compatible registry)
|
||||
- Outbound HTTPS access to `d18car1k0ff81h.cloudfront.net` (Six Flags API) and `queue-times.com` (live ride data)
|
||||
- A reverse proxy (Traefik, nginx, Caddy, etc.) is expected to sit in front for TLS termination and domain routing, but is not included in this repository
|
||||
|
||||
See [Architecture](ARCHITECTURE.md) for detailed system design.
|
||||
|
||||
---
|
||||
|
||||
## Docker Images
|
||||
|
||||
### Multi-Stage Build
|
||||
|
||||
The project uses a single `Dockerfile` with four stages producing two final images:
|
||||
|
||||
```
|
||||
builder backend-deps
|
||||
(Next.js build) (native modules)
|
||||
| |
|
||||
v v
|
||||
web backend
|
||||
(final) (final)
|
||||
```
|
||||
|
||||
| Stage | Base | Purpose |
|
||||
|-------|------|---------|
|
||||
| `builder` | `node:22-bookworm-slim` | `npm ci` + `npm run build` -- produces Next.js standalone output |
|
||||
| `backend-deps` | `node:22-bookworm-slim` | Installs `python3`, `make`, `g++` for `better-sqlite3` native compilation, then `npm ci` |
|
||||
| `web` (final) | `node:22-bookworm-slim` | Copies standalone output from `builder`. Non-root user. ~150MB. |
|
||||
| `backend` (final) | `node:22-bookworm-slim` | Copies `node_modules` from `backend-deps` + source code. Volume for SQLite. Non-root user. ~200MB. |
|
||||
|
||||
### Image Tags
|
||||
|
||||
```
|
||||
{registry}/{owner}/thoosiecalendar:web
|
||||
{registry}/{owner}/thoosiecalendar:backend
|
||||
```
|
||||
|
||||
### Building Locally
|
||||
|
||||
```bash
|
||||
# Build web image
|
||||
docker build --target web -t thoosiecalendar:web .
|
||||
|
||||
# Build backend image
|
||||
docker build --target backend -t thoosiecalendar:backend .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose
|
||||
|
||||
The production `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
web:
|
||||
image: gitea.thewrightserver.net/josh/thoosiecalendar:web
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- BACKEND_URL=http://backend:3001 # Docker internal networking
|
||||
- TZ=America/New_York
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
image: gitea.thewrightserver.net/josh/thoosiecalendar:backend
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- park_data:/app/backend/data # SQLite database persistence
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- TZ=America/New_York # Timezone for cron schedules
|
||||
- PARK_HOURS_STALENESS_HOURS=72 # Hours before re-fetching park data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
park_data: # Named volume for database files
|
||||
```
|
||||
|
||||
**Networking:** The `web` container reaches the backend via Docker's internal DNS at `http://backend:3001`. The backend port is also exposed to the host for manual API access during troubleshooting.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Web Container
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `BACKEND_URL` | `http://localhost:3001` | Backend API base URL. Set to `http://backend:3001` in Docker Compose for internal networking. |
|
||||
| `NODE_ENV` | -- | Set to `production` in Docker. |
|
||||
| `NEXT_TELEMETRY_DISABLED` | `1` | Disables Next.js telemetry (set in Dockerfile). |
|
||||
| `PORT` | `3000` | Server listen port (set in Dockerfile). |
|
||||
| `HOSTNAME` | `0.0.0.0` | Bind address (set in Dockerfile to allow external access). |
|
||||
|
||||
### Backend Container
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TZ` | `UTC` | Process timezone. Controls when cron jobs fire. Set to `America/New_York` in production so schedules align with US Eastern parks. |
|
||||
| `PARK_HOURS_STALENESS_HOURS` | `72` | Hours before park schedule data is considered stale and re-fetched. Lower values increase API load; higher values increase data lag. |
|
||||
| `RATE_LIMIT_PER_MIN` | `60` | Per-IP request limit for the public API. Over-limit requests return `429 Too Many Requests` with a `Retry-After` header. Enforced by `backend/src/middleware/rate-limit.ts`. Behind a proxy, ensure `x-forwarded-for` is set or every client looks like the proxy IP. |
|
||||
| `NODE_ENV` | -- | Set to `production` in Docker. |
|
||||
| `PORT` | `3001` | Server listen port. |
|
||||
|
||||
`backend/src/config.ts` parses and validates these at startup. A bad value (e.g. `PORT=foo`) fails fast with a thrown `Error` rather than surfacing in a request handler later.
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
### Gitea Actions Workflow
|
||||
|
||||
**File:** `.gitea/workflows/deploy.yml`
|
||||
|
||||
**Trigger:** Push to `main` branch.
|
||||
|
||||
**Steps:**
|
||||
1. Checkout code (`actions/checkout@v4`)
|
||||
2. Log in to Gitea container registry
|
||||
3. Build and push `web` image (`docker/build-push-action@v6`, target: `web`)
|
||||
4. Build and push `backend` image (`docker/build-push-action@v6`, target: `backend`)
|
||||
|
||||
### Required Configuration
|
||||
|
||||
| Type | Name | Description |
|
||||
|------|------|-------------|
|
||||
| Variable | `REGISTRY` | Container registry URL (e.g. `gitea.thewrightserver.net`) |
|
||||
| Secret | `REGISTRY_TOKEN` | Authentication token for the registry |
|
||||
|
||||
These are configured in the Gitea repository settings under **Settings > Actions > Secrets** and **Settings > Actions > Variables**.
|
||||
|
||||
### Setting Up CI/CD from Scratch
|
||||
|
||||
1. Create a Gitea repository
|
||||
2. Add `REGISTRY` as a repository variable (Settings > Actions > Variables)
|
||||
3. Add `REGISTRY_TOKEN` as a repository secret (Settings > Actions > Secrets)
|
||||
4. Push to `main` -- the workflow triggers automatically
|
||||
5. Pull images on your Docker host: `docker compose pull && docker compose up -d`
|
||||
|
||||
---
|
||||
|
||||
## Initial Deployment Checklist
|
||||
|
||||
1. **Create a `docker-compose.yml`** on your Docker host (see the Docker Compose section above, or use the one from the repository).
|
||||
|
||||
2. **Pull and start the containers:**
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
3. **Verify the backend started:**
|
||||
```bash
|
||||
docker compose logs backend
|
||||
# Look for (structured log lines, see the Log Reference section):
|
||||
# [INFO] [startup] database initialized
|
||||
# [INFO] [scheduler] cron jobs registered ...
|
||||
# [INFO] [startup] listening url=http://localhost:3001
|
||||
```
|
||||
|
||||
4. **Check database status (will be empty on first run):**
|
||||
```bash
|
||||
curl http://localhost:3001/api/status
|
||||
# { "status": "ok", "database": { "totalDays": 0, ... } }
|
||||
```
|
||||
|
||||
5. **Trigger the initial data scrape:**
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/scrape/trigger?scope=full
|
||||
```
|
||||
This scrapes all 12 months for all 24 parks with a 1-second delay between parks. **Expected duration: 5-10 minutes.**
|
||||
|
||||
6. **Verify data was scraped:**
|
||||
```bash
|
||||
curl http://localhost:3001/api/status
|
||||
# totalDays should be ~8000-9000
|
||||
```
|
||||
|
||||
7. **Open the web UI:** Navigate to `http://your-host:3000`.
|
||||
|
||||
The cron scheduler starts automatically and will keep data fresh going forward.
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
- The SQLite database lives in a named Docker volume (`park_data`), so it persists across container recreations.
|
||||
- Schema migrations are applied automatically on backend startup. New columns are added via `ALTER TABLE ... ADD COLUMN` wrapped in try/catch -- if the column already exists, the error is silently caught.
|
||||
- No manual migration steps are needed.
|
||||
|
||||
---
|
||||
|
||||
## Backup and Restore
|
||||
|
||||
### What to Back Up
|
||||
|
||||
The SQLite database at `/app/backend/data/parks.db` inside the `park_data` Docker volume. WAL journal files (`parks.db-wal` and `parks.db-shm`) must be included for a consistent backup.
|
||||
|
||||
### Backup Methods
|
||||
|
||||
**Method 1: Copy from the container**
|
||||
```bash
|
||||
docker compose cp backend:/app/backend/data/parks.db ./backup/parks.db
|
||||
docker compose cp backend:/app/backend/data/parks.db-wal ./backup/parks.db-wal 2>/dev/null
|
||||
docker compose cp backend:/app/backend/data/parks.db-shm ./backup/parks.db-shm 2>/dev/null
|
||||
```
|
||||
|
||||
**Method 2: Mount the volume to the host**
|
||||
Add a bind mount in `docker-compose.yml`:
|
||||
```yaml
|
||||
volumes:
|
||||
- ./data:/app/backend/data
|
||||
```
|
||||
|
||||
### Restore
|
||||
|
||||
1. Stop the backend: `docker compose stop backend`
|
||||
2. Replace the database files in the volume
|
||||
3. Restart: `docker compose start backend`
|
||||
|
||||
### Note on Reproducibility
|
||||
|
||||
All data is sourced from external APIs and is fully reproducible. If the database is lost, simply restart the backend (which auto-creates an empty database) and trigger a full scrape:
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/scrape/trigger?scope=full
|
||||
```
|
||||
|
||||
Backups are recommended for continuity (avoiding the 5-10 minute re-scrape window) but are not critical.
|
||||
|
||||
---
|
||||
|
||||
## Scheduler Operations
|
||||
|
||||
### Tiered Cron Schedule
|
||||
|
||||
The backend runs five scraping tiers via `node-cron`:
|
||||
|
||||
| Tier | Cron Expression | Schedule | Scope | Delay |
|
||||
|------|-----------------|----------|-------|-------|
|
||||
| 1 | `0 * * 3-12 *` | Hourly, March through December | Today's hours for all parks | 500ms |
|
||||
| 2 | `0 */6 * * *` | Every 6 hours | Current month for all parks | 1000ms |
|
||||
| 3 | `0 3,15 * * *` | 3 AM and 3 PM | Current + next month | 1000ms |
|
||||
| 4 | `0 3 * * *` | Daily at 3 AM | Full year (all 12 months) | 1000ms |
|
||||
| 5 | `*/5 * * * *` | Every 5 minutes | Wait-time samples for currently-open parks into `ride_wait_samples` | parallel chunks of 6 |
|
||||
|
||||
**Staleness:** Tiers 2-4 skip any park-month that was scraped within `PARK_HOURS_STALENESS_HOURS` (default 72h). Tier 1 always fetches (uses diff-before-write instead). Tier 5 only samples parks whose `park_days` row marks them open today *and* whose current local time is inside the operating window (with a 1-hour closing buffer).
|
||||
|
||||
**Off-season:** Tier 1 only runs from March through December. The month constraint `3-12` in the cron expression skips January and February when most parks are closed. Tier 5 runs year-round but is effectively a no-op when no parks are open.
|
||||
|
||||
**Concurrency latches:** Every tier is wrapped in `withLatch()` (see `backend/src/services/scheduler.ts`). If a tick is still running when the next would fire, the new tick is *skipped* and logged with a `previous run still in progress` warning rather than stacking. Each tier has its own latch so a slow Tier-4 doesn't block Tier-5's 5-minute cadence.
|
||||
|
||||
**Weather-delayed parks skipped from sampling:** Tier 5 detects the "rides exist but all closed during scheduled hours" case and skips writes for that park, so a storm doesn't poison the uptime statistics with hours of `is_open=0` samples.
|
||||
|
||||
### Startup Behavior
|
||||
|
||||
On boot, the scheduler checks `getParkDayCount()` against a threshold of 50 rows:
|
||||
|
||||
- **Empty / nearly-empty database** (< 50 rows): runs `scrapeToday()` followed by `scrapeFullYear()` in sequence. Logs `[scheduler.startup]` lines for each phase.
|
||||
- **Populated database** (≥ 50 rows): skips the startup scrape and relies on cron tiers. Logs `skipping startup scrape — relying on cron`.
|
||||
|
||||
This replaces the earlier behavior of full-scraping on every container start, which doubled outbound API load and delayed readiness on every deploy.
|
||||
|
||||
### Timezone Sensitivity
|
||||
|
||||
Cron expressions execute in the process timezone, controlled by the `TZ` environment variable. In production this is set to `America/New_York` so that "3 AM" aligns with US Eastern time.
|
||||
|
||||
The per-park timezone (e.g. `America/Los_Angeles` for Magic Mountain) is used separately for operating window detection -- it does not affect cron schedule timing.
|
||||
|
||||
### The 3 AM Switchover
|
||||
|
||||
`getTodayLocal()` in `lib/env.ts` implements a 3 AM local-time switchover: before 3 AM, the system considers it "yesterday." This prevents the calendar from flipping to the next day at midnight while park visitors are still out. The switchover uses the server's local time (influenced by `TZ`), not individual park timezones.
|
||||
|
||||
---
|
||||
|
||||
## Manual Scraping
|
||||
|
||||
Trigger a scrape at any time via the backend API:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/scrape/trigger?scope=<scope>
|
||||
```
|
||||
|
||||
### Scope Options
|
||||
|
||||
| Scope | Behavior | Duration |
|
||||
|-------|----------|----------|
|
||||
| `today` | Fetches today's hours for all 24 parks. Diffs against database before writing. 500ms delay. | ~15s |
|
||||
| `month` | Current month for all parks. Respects staleness window. 1000ms delay. | ~30s |
|
||||
| `upcoming` | Current + next month. Respects staleness. | ~1min |
|
||||
| `full` | All 12 months. Respects staleness. | ~5-10min |
|
||||
| `force` | All 12 months. **Ignores staleness** -- forces re-fetch of everything. | ~5-10min |
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"scope": "today",
|
||||
"fetched": 24,
|
||||
"skipped": 0,
|
||||
"errors": 0,
|
||||
"updated": 3,
|
||||
"startedAt": "2026-04-23T14:00:00.000Z",
|
||||
"finishedAt": "2026-04-23T14:00:12.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Typical Use Cases
|
||||
|
||||
- **After initial deployment:** `scope=full` to populate the database
|
||||
- **After an extended outage:** `scope=force` to refresh all data regardless of staleness
|
||||
- **Investigating a specific park:** `scope=today` to get fresh data quickly
|
||||
- **Before peak season:** `scope=full` to ensure complete coverage
|
||||
|
||||
---
|
||||
|
||||
## Health Monitoring
|
||||
|
||||
### Health Endpoint
|
||||
|
||||
```bash
|
||||
curl http://localhost:3001/api/status
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"uptime": 86400,
|
||||
"parks": 24,
|
||||
"database": {
|
||||
"totalDays": 8760,
|
||||
"lastScrape": "2026-04-23T14:00:12.000Z"
|
||||
},
|
||||
"lastScrapeResult": {
|
||||
"scope": "today",
|
||||
"fetched": 24,
|
||||
"skipped": 0,
|
||||
"errors": 0,
|
||||
"updated": 3,
|
||||
"startedAt": "2026-04-23T14:00:00.000Z",
|
||||
"finishedAt": "2026-04-23T14:00:12.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key Metrics
|
||||
|
||||
| Metric | Expected Value | Concern If |
|
||||
|--------|---------------|------------|
|
||||
| `status` | `"ok"` | Not `"ok"` (always `"ok"` currently, but confirms the endpoint is reachable) |
|
||||
| `uptime` | Increasing | Drops to 0 (container restarted) |
|
||||
| `database.totalDays` | 8,000-9,000 (full year) | Much lower (scraping not running) or 0 (empty database) |
|
||||
| `database.lastScrape` | Within the last hour (during operating season) | More than a few hours old (scheduler may be broken) |
|
||||
| `lastScrapeResult.errors` | 0 | Consistently high (API may be blocking requests) |
|
||||
|
||||
### Suggested Alerting
|
||||
|
||||
- Alert if `database.lastScrape` is more than 12 hours old during operating season (March-December)
|
||||
- Alert if `lastScrapeResult.errors` exceeds 5 on consecutive scrapes
|
||||
- Alert if the health endpoint is unreachable
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No data showing in the calendar
|
||||
|
||||
1. **Check if the backend is running:**
|
||||
```bash
|
||||
docker compose logs backend --tail 50
|
||||
```
|
||||
Look for an `[INFO] [startup] listening url=http://localhost:3001` line.
|
||||
|
||||
2. **Check if the database has data:**
|
||||
```bash
|
||||
curl http://localhost:3001/api/status | jq .database.totalDays
|
||||
```
|
||||
If 0, trigger a manual scrape: `curl -X POST http://localhost:3001/api/scrape/trigger?scope=full`
|
||||
|
||||
3. **Check the BACKEND_URL in the web container:**
|
||||
```bash
|
||||
docker compose exec web env | grep BACKEND_URL
|
||||
```
|
||||
Should be `http://backend:3001` (not localhost, which won't resolve inside Docker).
|
||||
|
||||
### Ride counts not appearing on the home page
|
||||
|
||||
- Ride counts only appear for parks that are **currently within their operating window**, as determined by `isWithinOperatingWindow()`. Outside of park hours, no rides are shown.
|
||||
- Queue-Times data is cached for 5 minutes. Recent park openings may take up to 5 minutes to appear.
|
||||
- **Weather delay** (blue badge) means the park is within its hours but all rides report closed -- this is expected during weather-related closures.
|
||||
- Verify the park has a Queue-Times mapping in `lib/queue-times-map.ts`.
|
||||
|
||||
### Stale data / not updating
|
||||
|
||||
1. **Check scheduler logs:**
|
||||
```bash
|
||||
docker compose logs backend | grep scheduler
|
||||
```
|
||||
You should see periodic `[scheduler] tier-X: scraping...` messages.
|
||||
|
||||
2. **Verify timezone:**
|
||||
```bash
|
||||
docker compose exec backend date
|
||||
```
|
||||
Should match the `TZ` environment variable (`America/New_York`).
|
||||
|
||||
3. **Check staleness threshold:** Data within `PARK_HOURS_STALENESS_HOURS` (default 72h) is skipped by tiers 2-4. If you recently changed park data manually, it may not be re-fetched until the staleness window expires.
|
||||
|
||||
4. **Force a refresh:**
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/scrape/trigger?scope=force
|
||||
```
|
||||
|
||||
### Rate limited by Six Flags API
|
||||
|
||||
- Look for `[rate-limited]` messages in the backend logs:
|
||||
```bash
|
||||
docker compose logs backend | grep rate-limited
|
||||
```
|
||||
- The client uses exponential backoff: 30s, 60s, 120s, then throws a `RateLimitError` and moves to the next park.
|
||||
- If rate limiting is persistent, increase `PARK_HOURS_STALENESS_HOURS` to reduce scrape frequency (e.g. 96 or 120).
|
||||
- The inter-park delay is hardcoded at 1000ms (500ms for the today tier) in `backend/src/services/scraper.ts`.
|
||||
|
||||
### Wrong timezone / incorrect dates
|
||||
|
||||
- `getTodayLocal()` uses the server's local time (set by `TZ` env var) with a 3 AM cutover. Before 3 AM, the system considers it "yesterday."
|
||||
- Each park has its own IANA timezone (stored in `lib/parks.ts`) used for operating window checks. The `TZ` env var only affects cron schedule timing and the "today" determination.
|
||||
- If dates seem off, check both `TZ` and the server's system clock:
|
||||
```bash
|
||||
docker compose exec backend date
|
||||
docker compose exec backend node -e "console.log(new Date().toISOString())"
|
||||
```
|
||||
|
||||
### Database corruption
|
||||
|
||||
If the database becomes corrupted (unlikely with SQLite WAL mode, but possible after a hard crash):
|
||||
|
||||
1. Stop the backend: `docker compose stop backend`
|
||||
2. Delete the database files from the volume:
|
||||
```bash
|
||||
docker compose run --rm backend rm -f /app/backend/data/parks.db /app/backend/data/parks.db-wal /app/backend/data/parks.db-shm
|
||||
```
|
||||
3. Restart: `docker compose start backend` (auto-creates empty database)
|
||||
4. Re-scrape: `curl -X POST http://localhost:3001/api/scrape/trigger?scope=full`
|
||||
|
||||
---
|
||||
|
||||
## Log Reference
|
||||
|
||||
The backend uses a small structured logger (`backend/src/log.ts`). Every line has the format:
|
||||
|
||||
```
|
||||
<ISO timestamp> [<LEVEL>] [<tag>] <message> key1=value1 key2=value2 …
|
||||
```
|
||||
|
||||
Levels are `INFO`, `WARN`, `ERROR`. `ERROR` writes to stderr; the others write to stdout. Grep-friendly: filter by tag (`grep '\[scheduler.tier1\]'`) or by key (`grep 'park=cedarpoint'`).
|
||||
|
||||
| Tag | Source | Meaning |
|
||||
|-----|--------|---------|
|
||||
| `startup` | `index.ts` | Config loaded, DB initialized, server listening |
|
||||
| `shutdown` | `index.ts` | `SIGTERM`/`SIGINT` received; graceful shutdown progress |
|
||||
| `http` | `index.ts` | One line per request: `method`, `path`, `status`, `ms` |
|
||||
| `scheduler` | `scheduler.ts` | Cron job registration summary on boot |
|
||||
| `scheduler.tier1` … `scheduler.tier5` | `scheduler.ts` | Each tier's tick; includes skip-due-to-latch warnings |
|
||||
| `scheduler.startup` | `scheduler.ts` | Result of the "database empty" startup scrape |
|
||||
| `today` / `month` | `scraper.ts` | Per-park / per-month scrape results |
|
||||
| `wait-sampler` | `wait-sampler.ts` | Tier-5 per-park sample writes, errors, weather-delay skips |
|
||||
| `rate-limit` | `middleware/rate-limit.ts` | `blocked` event with `ip`, `count`, `retryAfter` |
|
||||
| `rides` | `routes/rides.ts` | Per-request warnings when upstream calls fail |
|
||||
| `rate-limited` | `lib/scrapers/sixflags.ts` | HTTP 429/503 from Six Flags with backoff timing |
|
||||
|
||||
**Example log output:**
|
||||
|
||||
```
|
||||
2026-04-23T14:00:00.012Z [INFO] [startup] config loaded port=3001 nodeEnv=production parkHoursStalenessHours=72 rateLimitPerMin=60
|
||||
2026-04-23T14:00:00.034Z [INFO] [startup] database initialized
|
||||
2026-04-23T14:00:00.041Z [INFO] [scheduler] cron jobs registered tiers="tier1=hourly(Mar-Dec) tier2=6h tier3=3am+3pm tier4=3am-daily tier5=5min"
|
||||
2026-04-23T14:00:00.042Z [INFO] [scheduler] skipping startup scrape — relying on cron existingRows=8742
|
||||
2026-04-23T14:00:00.045Z [INFO] [startup] listening url=http://localhost:3001
|
||||
2026-04-23T14:00:00.123Z [INFO] [http] GET /api/calendar/week status=200 ms=18
|
||||
2026-04-23T14:00:10.001Z [INFO] [scheduler.tier1] scraping today
|
||||
2026-04-23T14:05:00.001Z [INFO] [scheduler.tier5] sample run complete parksSampled=14 parksSkipped=10 samplesWritten=612 weatherDelayed=0 errors=0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
| Aspect | Current Setting | Notes |
|
||||
|--------|----------------|-------|
|
||||
| SQLite WAL mode | Enabled | Allows concurrent reads during writes. No configuration needed. |
|
||||
| In-memory cache | TtlCache (5 min TTL) | Bounded by park count -- at most ~72 entries (24 parks x 3 caches). Memory impact is negligible. |
|
||||
| Staleness window | 72 hours | Controls how often park data is re-fetched from the API. Lower values = fresher data but more API calls and higher rate-limit risk. |
|
||||
| Inter-park delay | 1000ms / 500ms | Hardcoded in `scraper.ts`. Provides respectful pacing against the Six Flags API. |
|
||||
| ISR revalidation | 60-300s per route | Controlled in Next.js fetch calls. Lower values = fresher pages but more backend requests. |
|
||||
| Next.js standalone | Enabled | Produces a minimal server bundle without unused dependencies. |
|
||||
@@ -0,0 +1,30 @@
|
||||
Six Flags / Cedar Fair live wait-times endpoints
|
||||
Pattern: https://d18car1k0ff81h.cloudfront.net/wait-times/park/{apiId}
|
||||
|
||||
# Six Flags branded parks
|
||||
Six Flags Great Adventure https://d18car1k0ff81h.cloudfront.net/wait-times/park/905
|
||||
Six Flags Magic Mountain https://d18car1k0ff81h.cloudfront.net/wait-times/park/906
|
||||
Six Flags Great America https://d18car1k0ff81h.cloudfront.net/wait-times/park/910
|
||||
Six Flags Over Georgia https://d18car1k0ff81h.cloudfront.net/wait-times/park/902
|
||||
Six Flags Over Texas https://d18car1k0ff81h.cloudfront.net/wait-times/park/901
|
||||
Six Flags St. Louis https://d18car1k0ff81h.cloudfront.net/wait-times/park/903
|
||||
Six Flags Fiesta Texas https://d18car1k0ff81h.cloudfront.net/wait-times/park/914
|
||||
Six Flags New England https://d18car1k0ff81h.cloudfront.net/wait-times/park/935
|
||||
Six Flags Discovery Kingdom https://d18car1k0ff81h.cloudfront.net/wait-times/park/936
|
||||
Six Flags Mexico https://d18car1k0ff81h.cloudfront.net/wait-times/park/960
|
||||
Six Flags Great Escape https://d18car1k0ff81h.cloudfront.net/wait-times/park/924
|
||||
Six Flags Darien Lake https://d18car1k0ff81h.cloudfront.net/wait-times/park/945
|
||||
|
||||
# Former Cedar Fair theme parks
|
||||
Cedar Point https://d18car1k0ff81h.cloudfront.net/wait-times/park/1
|
||||
Knott's Berry Farm https://d18car1k0ff81h.cloudfront.net/wait-times/park/4
|
||||
Canada's Wonderland https://d18car1k0ff81h.cloudfront.net/wait-times/park/40
|
||||
Carowinds https://d18car1k0ff81h.cloudfront.net/wait-times/park/30
|
||||
Kings Dominion https://d18car1k0ff81h.cloudfront.net/wait-times/park/25
|
||||
Kings Island https://d18car1k0ff81h.cloudfront.net/wait-times/park/20
|
||||
Valleyfair https://d18car1k0ff81h.cloudfront.net/wait-times/park/14
|
||||
Worlds of Fun https://d18car1k0ff81h.cloudfront.net/wait-times/park/6
|
||||
Michigan's Adventure https://d18car1k0ff81h.cloudfront.net/wait-times/park/12
|
||||
Dorney Park https://d18car1k0ff81h.cloudfront.net/wait-times/park/8
|
||||
California's Great America https://d18car1k0ff81h.cloudfront.net/wait-times/park/35
|
||||
Frontier City https://d18car1k0ff81h.cloudfront.net/wait-times/park/943
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Single source of truth for the backend URL used by Next.js server
|
||||
* components. Defaults to localhost in development; throws at *request time*
|
||||
* in production if BACKEND_URL isn't set so a misdeployed container fails
|
||||
* with a clear message instead of silently pointing at localhost. The check
|
||||
* is lazy so Next.js build-time page-data collection doesn't trip it.
|
||||
*/
|
||||
|
||||
let warned = false;
|
||||
|
||||
export function getBackendUrl(): string {
|
||||
const explicit = process.env.BACKEND_URL;
|
||||
if (explicit) return explicit;
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error(
|
||||
"BACKEND_URL env var is required in production. " +
|
||||
"Set it to the backend service URL (e.g. http://backend:3001).",
|
||||
);
|
||||
}
|
||||
if (!warned) {
|
||||
warned = true;
|
||||
console.warn("[lib/api] BACKEND_URL unset — defaulting to http://localhost:3001 (dev only)");
|
||||
}
|
||||
return "http://localhost:3001";
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch JSON from the backend with a default revalidate window. Returns
|
||||
* `null` on network failure or non-2xx status — callers handle the null
|
||||
* to render a graceful fallback instead of crashing the server render.
|
||||
*/
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
options: { revalidate?: number; noStore?: boolean } = {},
|
||||
): Promise<T | null> {
|
||||
const { revalidate = 60, noStore = false } = options;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${getBackendUrl()}${path}`,
|
||||
noStore ? { cache: "no-store" } : { next: { revalidate } },
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { normalizeForMatch } from "./coaster-match";
|
||||
|
||||
export const COASTER_LISTS: Record<string, string[]> = {
|
||||
greatadventure: [
|
||||
"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",
|
||||
],
|
||||
magicmountain: [
|
||||
"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",
|
||||
],
|
||||
greatamerica: [
|
||||
"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",
|
||||
],
|
||||
overgeorgia: [
|
||||
"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",
|
||||
],
|
||||
overtexas: [
|
||||
"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",
|
||||
],
|
||||
stlouis: [
|
||||
"Ninja",
|
||||
"River King Mine Train",
|
||||
"Mr. Freeze Reverse Blast",
|
||||
"Batman The Ride",
|
||||
"Screamin' Eagle",
|
||||
"Boss",
|
||||
"Pandemonium",
|
||||
"American Thunder",
|
||||
"Boomerang",
|
||||
"Rookie Racer",
|
||||
],
|
||||
fiestatexas: [
|
||||
"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",
|
||||
],
|
||||
newengland: [
|
||||
"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",
|
||||
],
|
||||
discoverykingdom: [
|
||||
"Roadrunner Express",
|
||||
"Medusa",
|
||||
"Cobra",
|
||||
"Flash: Vertical Velocity",
|
||||
"Kong",
|
||||
"Boomerang",
|
||||
"Superman Ultimate Flight",
|
||||
"Joker",
|
||||
"Batman The Ride",
|
||||
"Sidewinder Safari",
|
||||
],
|
||||
mexico: [
|
||||
"Tsunami",
|
||||
"Superman Krypton Coaster",
|
||||
"Batgirl Batarang",
|
||||
"Batman The Ride",
|
||||
"Superman el Último Escape",
|
||||
"Dark Knight",
|
||||
"Joker",
|
||||
"Medusa Steel Coaster",
|
||||
"Wonder Woman",
|
||||
"Speedway Stunt Coaster",
|
||||
],
|
||||
greatescape: [
|
||||
"Comet",
|
||||
"Steamin' Demon",
|
||||
"Flashback",
|
||||
"Canyon Blaster",
|
||||
"Frankie's Mine Train",
|
||||
"Bobcat",
|
||||
],
|
||||
darienlake: [
|
||||
"Predator",
|
||||
"Viper",
|
||||
"Mind Eraser",
|
||||
"Boomerang",
|
||||
"Ride of Steel",
|
||||
"Hoot N Holler",
|
||||
"Moto Coaster",
|
||||
"Tantrum",
|
||||
],
|
||||
cedarpoint: [
|
||||
"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",
|
||||
],
|
||||
knotts: [
|
||||
"Jaguar!",
|
||||
"GhostRider",
|
||||
"Xcelerator",
|
||||
"Silver Bullet",
|
||||
"Sierra Sidewinder",
|
||||
"Pony Express",
|
||||
"Coast Rider",
|
||||
"HangTime",
|
||||
"Snoopy's Tenderpaw Twister Coaster",
|
||||
],
|
||||
canadaswonderland: [
|
||||
"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",
|
||||
],
|
||||
carowinds: [
|
||||
"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",
|
||||
],
|
||||
kingsdominion: [
|
||||
"Racer 75",
|
||||
"Woodstock Express",
|
||||
"Grizzly",
|
||||
"Flight of Fear",
|
||||
"Reptilian",
|
||||
"Great Pumpkin Coaster",
|
||||
"Apple Zapple",
|
||||
"Backlot Stunt Coaster",
|
||||
"Dominator",
|
||||
"Pantherian",
|
||||
"Twisted Timbers",
|
||||
"Tumbili",
|
||||
"Rapterra",
|
||||
],
|
||||
kingsisland: [
|
||||
"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",
|
||||
],
|
||||
valleyfair: [
|
||||
"High Roller",
|
||||
"Corkscrew",
|
||||
"Excalibur",
|
||||
"Wild Thing",
|
||||
"Mad Mouse",
|
||||
"Steel Venom",
|
||||
"Renegade",
|
||||
"Cosmic Coaster",
|
||||
],
|
||||
worldsoffun: [
|
||||
"Timber Wolf",
|
||||
"Cosmic Coaster",
|
||||
"Mamba",
|
||||
"Spinning Dragons",
|
||||
"Patriot",
|
||||
"Prowler",
|
||||
"Zambezi Zinger",
|
||||
"Boomerang",
|
||||
],
|
||||
miadventure: [
|
||||
"Corkscrew",
|
||||
"Wolverine Wildcat",
|
||||
"Zach's Zoomer",
|
||||
"Shivering Timbers",
|
||||
"Mad Mouse",
|
||||
"Thunderhawk",
|
||||
"Woodstock Express",
|
||||
],
|
||||
dorneypark: [
|
||||
"Thunderhawk",
|
||||
"Steel Force",
|
||||
"Wild Mouse",
|
||||
"Woodstock Express",
|
||||
"Talon",
|
||||
"Hydra the Revenge",
|
||||
"Possessed",
|
||||
"Iron Menace",
|
||||
],
|
||||
cagreatamerica: [
|
||||
"Demon",
|
||||
"Grizzly",
|
||||
"Woodstock Express",
|
||||
"Patriot",
|
||||
"Flight Deck",
|
||||
"Lucy's Crabbie Cabbies",
|
||||
"Psycho Mouse",
|
||||
"Gold Striker",
|
||||
"RailBlazer",
|
||||
],
|
||||
frontiercity: [
|
||||
"Silver Bullet",
|
||||
"Wildcat",
|
||||
"Diamondback",
|
||||
"Steel Lasso",
|
||||
"Frankie's Mine Train",
|
||||
],
|
||||
};
|
||||
|
||||
export function getCoasterSet(parkId: string): Set<string> | null {
|
||||
const coasters = COASTER_LISTS[parkId];
|
||||
if (!coasters || coasters.length === 0) return null;
|
||||
return new Set(coasters.map(normalizeForMatch));
|
||||
}
|
||||
|
||||
export function hasCoasterData(): boolean {
|
||||
return Object.values(COASTER_LISTS).some((list) => list.length > 0);
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "data");
|
||||
const DB_PATH = path.join(DATA_DIR, "parks.db");
|
||||
|
||||
export type DbInstance = Database.Database;
|
||||
|
||||
export function openDb(): Database.Database {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS park_days (
|
||||
park_id TEXT NOT NULL,
|
||||
date TEXT NOT NULL, -- YYYY-MM-DD
|
||||
is_open INTEGER NOT NULL DEFAULT 0,
|
||||
hours_label TEXT,
|
||||
special_type TEXT, -- 'passholder_preview' | null
|
||||
scraped_at TEXT NOT NULL,
|
||||
PRIMARY KEY (park_id, date)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS park_api_ids (
|
||||
park_id TEXT PRIMARY KEY,
|
||||
api_id INTEGER NOT NULL,
|
||||
api_abbreviation TEXT,
|
||||
api_name TEXT,
|
||||
discovered_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
// Migrate existing databases that predate the special_type column
|
||||
try {
|
||||
db.exec(`ALTER TABLE park_days ADD COLUMN special_type TEXT`);
|
||||
} catch {
|
||||
// Column already exists — safe to ignore
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export function upsertDay(
|
||||
db: Database.Database,
|
||||
parkId: string,
|
||||
date: string,
|
||||
isOpen: boolean,
|
||||
hoursLabel?: string,
|
||||
specialType?: string
|
||||
) {
|
||||
// Today and future dates: full upsert — hours can change (e.g. weather delays,
|
||||
// early closures) and the dateless API endpoint now returns today's live data.
|
||||
//
|
||||
// Past dates: INSERT-only — never overwrite once the day has passed.
|
||||
db.prepare(`
|
||||
INSERT INTO park_days (park_id, date, is_open, hours_label, special_type, scraped_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (park_id, date) DO UPDATE SET
|
||||
is_open = excluded.is_open,
|
||||
hours_label = excluded.hours_label,
|
||||
special_type = excluded.special_type,
|
||||
scraped_at = excluded.scraped_at
|
||||
WHERE park_days.date >= date('now')
|
||||
`).run(parkId, date, isOpen ? 1 : 0, hoursLabel ?? null, specialType ?? null, new Date().toISOString());
|
||||
}
|
||||
|
||||
export interface DayData {
|
||||
isOpen: boolean;
|
||||
hoursLabel: string | null;
|
||||
specialType: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns scraped data for all parks across a date range.
|
||||
* Shape: { parkId: { 'YYYY-MM-DD': DayData } }
|
||||
* Missing dates mean that date hasn't been scraped yet (not necessarily closed).
|
||||
*/
|
||||
export function getDateRange(
|
||||
db: Database.Database,
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Record<string, Record<string, DayData>> {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT park_id, date, is_open, hours_label, special_type
|
||||
FROM park_days
|
||||
WHERE date >= ? AND date <= ?`
|
||||
)
|
||||
.all(startDate, endDate) as {
|
||||
park_id: string;
|
||||
date: string;
|
||||
is_open: number;
|
||||
hours_label: string | null;
|
||||
special_type: string | null;
|
||||
}[];
|
||||
|
||||
const result: Record<string, Record<string, DayData>> = {};
|
||||
for (const row of rows) {
|
||||
if (!result[row.park_id]) result[row.park_id] = {};
|
||||
result[row.park_id][row.date] = {
|
||||
isOpen: row.is_open === 1,
|
||||
hoursLabel: row.hours_label,
|
||||
specialType: row.special_type,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns scraped DayData for a single park for an entire month.
|
||||
* Shape: { 'YYYY-MM-DD': DayData }
|
||||
*/
|
||||
export function getParkMonthData(
|
||||
db: Database.Database,
|
||||
parkId: string,
|
||||
year: number,
|
||||
month: number,
|
||||
): Record<string, DayData> {
|
||||
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT date, is_open, hours_label, special_type
|
||||
FROM park_days
|
||||
WHERE park_id = ? AND date LIKE ? || '-%'
|
||||
ORDER BY date`
|
||||
)
|
||||
.all(parkId, prefix) as {
|
||||
date: string;
|
||||
is_open: number;
|
||||
hours_label: string | null;
|
||||
special_type: string | null;
|
||||
}[];
|
||||
|
||||
const result: Record<string, DayData> = {};
|
||||
for (const row of rows) {
|
||||
result[row.date] = {
|
||||
isOpen: row.is_open === 1,
|
||||
hoursLabel: row.hours_label,
|
||||
specialType: row.special_type,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Returns a map of parkId → boolean[] (index 0 = day 1) for a given month. */
|
||||
export function getMonthCalendar(
|
||||
db: Database.Database,
|
||||
year: number,
|
||||
month: number
|
||||
): Record<string, boolean[]> {
|
||||
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT park_id, date, is_open
|
||||
FROM park_days
|
||||
WHERE date LIKE ? || '-%'
|
||||
ORDER BY date`
|
||||
)
|
||||
.all(prefix) as { park_id: string; date: string; is_open: number }[];
|
||||
|
||||
const result: Record<string, boolean[]> = {};
|
||||
for (const row of rows) {
|
||||
if (!result[row.park_id]) result[row.park_id] = [];
|
||||
const day = parseInt(row.date.slice(8), 10);
|
||||
result[row.park_id][day - 1] = row.is_open === 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* Two reasons to skip:
|
||||
* 1. The month is entirely in the past — the API will never return data for
|
||||
* those dates again, so re-scraping wastes a call and risks nothing but
|
||||
* wasted time. Historical records are preserved forever by upsertDay.
|
||||
* 2. The month was scraped within the last 7 days — data is still fresh.
|
||||
*/
|
||||
export function isMonthScraped(
|
||||
db: Database.Database,
|
||||
parkId: string,
|
||||
year: number,
|
||||
month: number
|
||||
): boolean {
|
||||
// Compute the last calendar day of this month (avoids timezone issues).
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
const lastDay = `${year}-${String(month).padStart(2, "0")}-${String(daysInMonth).padStart(2, "0")}`;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Past month — history is locked in, no API data available, always skip.
|
||||
if (lastDay < today) return true;
|
||||
|
||||
// Current/future month — skip only if recently scraped.
|
||||
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT MAX(scraped_at) AS last_scraped
|
||||
FROM park_days
|
||||
WHERE park_id = ? AND date LIKE ? || '-%'`
|
||||
)
|
||||
.get(parkId, prefix) as { last_scraped: string | null };
|
||||
|
||||
if (!row.last_scraped) return false;
|
||||
const ageMs = Date.now() - new Date(row.last_scraped).getTime();
|
||||
return ageMs < STALE_AFTER_MS;
|
||||
}
|
||||
|
||||
export function getApiId(db: Database.Database, parkId: string): number | null {
|
||||
const row = db
|
||||
.prepare("SELECT api_id FROM park_api_ids WHERE park_id = ?")
|
||||
.get(parkId) as { api_id: number } | undefined;
|
||||
return row?.api_id ?? null;
|
||||
}
|
||||
|
||||
export function setApiId(
|
||||
db: Database.Database,
|
||||
parkId: string,
|
||||
apiId: number,
|
||||
apiAbbreviation?: string,
|
||||
apiName?: string
|
||||
) {
|
||||
db.prepare(`
|
||||
INSERT INTO park_api_ids (park_id, api_id, api_abbreviation, api_name, discovered_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (park_id) DO UPDATE SET
|
||||
api_id = excluded.api_id,
|
||||
api_abbreviation = excluded.api_abbreviation,
|
||||
api_name = excluded.api_name,
|
||||
discovered_at = excluded.discovered_at
|
||||
`).run(
|
||||
parkId,
|
||||
apiId,
|
||||
apiAbbreviation ?? null,
|
||||
apiName ?? null,
|
||||
new Date().toISOString()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next park+month to scrape.
|
||||
* Priority: never-scraped first, then oldest scraped_at.
|
||||
* Considers current month through monthsAhead months into the future.
|
||||
*/
|
||||
export function getNextScrapeTarget(
|
||||
db: Database.Database,
|
||||
parkIds: string[],
|
||||
monthsAhead = 12
|
||||
): { parkId: string; year: number; month: number } | null {
|
||||
const now = new Date();
|
||||
|
||||
const candidates: {
|
||||
parkId: string;
|
||||
year: number;
|
||||
month: number;
|
||||
lastScraped: string | null;
|
||||
}[] = [];
|
||||
|
||||
for (const parkId of parkIds) {
|
||||
for (let i = 0; i < monthsAhead; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() + i, 1);
|
||||
const year = d.getFullYear();
|
||||
const month = d.getMonth() + 1;
|
||||
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
||||
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT MAX(scraped_at) AS last_scraped
|
||||
FROM park_days
|
||||
WHERE park_id = ? AND date LIKE ? || '-%'`
|
||||
)
|
||||
.get(parkId, prefix) as { last_scraped: string | null };
|
||||
|
||||
candidates.push({ parkId, year, month, lastScraped: row.last_scraped });
|
||||
}
|
||||
}
|
||||
|
||||
// Never-scraped (null) first, then oldest scraped_at
|
||||
candidates.sort((a, b) => {
|
||||
if (!a.lastScraped && !b.lastScraped) return 0;
|
||||
if (!a.lastScraped) return -1;
|
||||
if (!b.lastScraped) return 1;
|
||||
return a.lastScraped.localeCompare(b.lastScraped);
|
||||
});
|
||||
|
||||
const top = candidates[0];
|
||||
return top ? { parkId: top.parkId, year: top.year, month: top.month } : null;
|
||||
}
|
||||
+44
-16
@@ -12,26 +12,54 @@ export function parseStalenessHours(envVar: string | undefined, defaultHours: nu
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultHours;
|
||||
}
|
||||
|
||||
const APP_TIMEZONE = "America/New_York";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns today's date as YYYY-MM-DD in Eastern time with a 3 AM switchover.
|
||||
* Uses Intl.DateTimeFormat so it works regardless of the system/container TZ.
|
||||
*/
|
||||
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}`;
|
||||
const fmt = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: APP_TIMEZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "numeric",
|
||||
hour12: false,
|
||||
});
|
||||
const parts = fmt.formatToParts(now);
|
||||
const hour = parseInt(parts.find((p) => p.type === "hour")!.value, 10) % 24;
|
||||
|
||||
const target = hour < 3 ? new Date(now.getTime() - 86_400_000) : now;
|
||||
return formatDateTZ(target, APP_TIMEZONE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Date as YYYY-MM-DD in a specific IANA timezone.
|
||||
*/
|
||||
export function formatDateTZ(d: Date, tz: string): string {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(d);
|
||||
const y = parts.find((p) => p.type === "year")!.value;
|
||||
const m = parts.find((p) => p.type === "month")!.value;
|
||||
const day = parts.find((p) => p.type === "day")!.value;
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Date as YYYY-MM-DD using its local (system-timezone) components.
|
||||
* Use this instead of d.toISOString().slice(0,10) which converts to UTC.
|
||||
*/
|
||||
export function formatDateLocal(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Outage detection for the ride detail page's Today chart.
|
||||
*
|
||||
* An outage is a contiguous run of samples with `isOpen === false`. We number
|
||||
* them chronologically (1-based) and report their duration so the chart can
|
||||
* render `<ReferenceArea>` bands with hover tooltips like "Outage #4 — 1h 28m".
|
||||
*
|
||||
* Pure module — no DOM, no Recharts — so it's cheap to unit test.
|
||||
*/
|
||||
|
||||
export interface OutageSample {
|
||||
recordedAt: string; // ISO 8601 UTC
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export interface Outage {
|
||||
n: number;
|
||||
startISO: string;
|
||||
endISO: string;
|
||||
/** Formatted HH:MM in the viewer's local timezone — matches the chart X-axis labels. */
|
||||
startTimeLabel: string;
|
||||
endTimeLabel: string;
|
||||
durationMin: number;
|
||||
}
|
||||
|
||||
const TIME_FMT = new Intl.DateTimeFormat([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Walk samples in chronological order and produce one Outage per contiguous
|
||||
* run of `!isOpen`. If the day ends mid-outage, the last sample becomes the
|
||||
* outage's `endISO` (still in progress).
|
||||
*/
|
||||
export function computeOutages(samples: OutageSample[]): Outage[] {
|
||||
const outages: Outage[] = [];
|
||||
let i = 0;
|
||||
let n = 0;
|
||||
while (i < samples.length) {
|
||||
if (samples[i].isOpen) { i++; continue; }
|
||||
const startSample = samples[i];
|
||||
while (i < samples.length && !samples[i].isOpen) i++;
|
||||
// i now points at the first open sample after the run, or samples.length.
|
||||
const endSample = i < samples.length ? samples[i] : samples[samples.length - 1];
|
||||
const startMs = Date.parse(startSample.recordedAt);
|
||||
const endMs = Date.parse(endSample.recordedAt);
|
||||
const durationMin = Math.max(0, Math.round((endMs - startMs) / 60_000));
|
||||
n += 1;
|
||||
outages.push({
|
||||
n,
|
||||
startISO: startSample.recordedAt,
|
||||
endISO: endSample.recordedAt,
|
||||
startTimeLabel: TIME_FMT.format(new Date(startSample.recordedAt)),
|
||||
endTimeLabel: TIME_FMT.format(new Date(endSample.recordedAt)),
|
||||
durationMin,
|
||||
});
|
||||
}
|
||||
return outages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a minute count as "47m" / "1h 28m" / "2h".
|
||||
*/
|
||||
export function formatOutageDuration(min: number): string {
|
||||
if (min < 60) return `${min}m`;
|
||||
const h = Math.floor(min / 60);
|
||||
const m = min % 60;
|
||||
return m === 0 ? `${h}h` : `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a lookup from time-label (HH:MM) → outage for each closed sample.
|
||||
* The chart uses this to attach outage metadata to each data point so the
|
||||
* custom tooltip can render outage-aware text without re-scanning.
|
||||
*/
|
||||
export function outageLookup(
|
||||
samples: OutageSample[],
|
||||
outages: Outage[],
|
||||
): Map<string, Outage> {
|
||||
const byTime = new Map<string, Outage>();
|
||||
let oIdx = 0;
|
||||
let runStart: number | null = null;
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const s = samples[i];
|
||||
if (!s.isOpen) {
|
||||
if (runStart === null) runStart = i;
|
||||
const outage = outages[oIdx];
|
||||
if (outage) byTime.set(TIME_FMT.format(new Date(s.recordedAt)), outage);
|
||||
} else if (runStart !== null) {
|
||||
runStart = null;
|
||||
oIdx += 1;
|
||||
}
|
||||
}
|
||||
return byTime;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export const PARKS: Park[] = [
|
||||
// ── Six Flags branded parks ──────────────────────────────────────────────
|
||||
{
|
||||
id: "greatadventure",
|
||||
apiId: 905,
|
||||
name: "Six Flags Great Adventure",
|
||||
shortName: "Great Adventure",
|
||||
chain: "sixflags",
|
||||
@@ -22,6 +23,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "magicmountain",
|
||||
apiId: 906,
|
||||
name: "Six Flags Magic Mountain",
|
||||
shortName: "Magic Mountain",
|
||||
chain: "sixflags",
|
||||
@@ -33,6 +35,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "greatamerica",
|
||||
apiId: 910,
|
||||
name: "Six Flags Great America",
|
||||
shortName: "Great America",
|
||||
chain: "sixflags",
|
||||
@@ -44,6 +47,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "overgeorgia",
|
||||
apiId: 902,
|
||||
name: "Six Flags Over Georgia",
|
||||
shortName: "Over Georgia",
|
||||
chain: "sixflags",
|
||||
@@ -55,6 +59,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "overtexas",
|
||||
apiId: 901,
|
||||
name: "Six Flags Over Texas",
|
||||
shortName: "Over Texas",
|
||||
chain: "sixflags",
|
||||
@@ -66,6 +71,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "stlouis",
|
||||
apiId: 903,
|
||||
name: "Six Flags St. Louis",
|
||||
shortName: "St. Louis",
|
||||
chain: "sixflags",
|
||||
@@ -77,6 +83,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "fiestatexas",
|
||||
apiId: 914,
|
||||
name: "Six Flags Fiesta Texas",
|
||||
shortName: "Fiesta Texas",
|
||||
chain: "sixflags",
|
||||
@@ -88,6 +95,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "newengland",
|
||||
apiId: 935,
|
||||
name: "Six Flags New England",
|
||||
shortName: "New England",
|
||||
chain: "sixflags",
|
||||
@@ -99,6 +107,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "discoverykingdom",
|
||||
apiId: 936,
|
||||
name: "Six Flags Discovery Kingdom",
|
||||
shortName: "Discovery Kingdom",
|
||||
chain: "sixflags",
|
||||
@@ -110,6 +119,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "mexico",
|
||||
apiId: 960,
|
||||
name: "Six Flags Mexico",
|
||||
shortName: "Mexico",
|
||||
chain: "sixflags",
|
||||
@@ -121,6 +131,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "greatescape",
|
||||
apiId: 924,
|
||||
name: "Six Flags Great Escape",
|
||||
shortName: "Great Escape",
|
||||
chain: "sixflags",
|
||||
@@ -132,6 +143,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "darienlake",
|
||||
apiId: 945,
|
||||
name: "Six Flags Darien Lake",
|
||||
shortName: "Darien Lake",
|
||||
chain: "sixflags",
|
||||
@@ -144,6 +156,7 @@ export const PARKS: Park[] = [
|
||||
// ── Former Cedar Fair theme parks ─────────────────────────────────────────
|
||||
{
|
||||
id: "cedarpoint",
|
||||
apiId: 1,
|
||||
name: "Cedar Point",
|
||||
shortName: "Cedar Point",
|
||||
chain: "sixflags",
|
||||
@@ -155,6 +168,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "knotts",
|
||||
apiId: 4,
|
||||
name: "Knott's Berry Farm",
|
||||
shortName: "Knott's",
|
||||
chain: "sixflags",
|
||||
@@ -166,6 +180,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "canadaswonderland",
|
||||
apiId: 40,
|
||||
name: "Canada's Wonderland",
|
||||
shortName: "Canada's Wonderland",
|
||||
chain: "sixflags",
|
||||
@@ -177,6 +192,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "carowinds",
|
||||
apiId: 30,
|
||||
name: "Carowinds",
|
||||
shortName: "Carowinds",
|
||||
chain: "sixflags",
|
||||
@@ -188,6 +204,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "kingsdominion",
|
||||
apiId: 25,
|
||||
name: "Kings Dominion",
|
||||
shortName: "Kings Dominion",
|
||||
chain: "sixflags",
|
||||
@@ -199,6 +216,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "kingsisland",
|
||||
apiId: 20,
|
||||
name: "Kings Island",
|
||||
shortName: "Kings Island",
|
||||
chain: "sixflags",
|
||||
@@ -210,6 +228,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "valleyfair",
|
||||
apiId: 14,
|
||||
name: "Valleyfair",
|
||||
shortName: "Valleyfair",
|
||||
chain: "sixflags",
|
||||
@@ -221,6 +240,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "worldsoffun",
|
||||
apiId: 6,
|
||||
name: "Worlds of Fun",
|
||||
shortName: "Worlds of Fun",
|
||||
chain: "sixflags",
|
||||
@@ -232,6 +252,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "miadventure",
|
||||
apiId: 12,
|
||||
name: "Michigan's Adventure",
|
||||
shortName: "Michigan's Adventure",
|
||||
chain: "sixflags",
|
||||
@@ -243,6 +264,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "dorneypark",
|
||||
apiId: 8,
|
||||
name: "Dorney Park",
|
||||
shortName: "Dorney Park",
|
||||
chain: "sixflags",
|
||||
@@ -254,6 +276,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "cagreatamerica",
|
||||
apiId: 35,
|
||||
name: "California's Great America",
|
||||
shortName: "CA Great America",
|
||||
chain: "sixflags",
|
||||
@@ -265,6 +288,7 @@ export const PARKS: Park[] = [
|
||||
},
|
||||
{
|
||||
id: "frontiercity",
|
||||
apiId: 943,
|
||||
name: "Frontier City",
|
||||
shortName: "Frontier City",
|
||||
chain: "sixflags",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* URL-safe slug generator for ride names.
|
||||
*
|
||||
* Used as a secondary key on the `rides` table — the primary key is
|
||||
* (park_id, qt_ride_id) so renames don't lose history. The slug is just
|
||||
* for pretty URLs.
|
||||
*
|
||||
* Steps:
|
||||
* 1. NFD-normalize to split accented letters into base + combining mark
|
||||
* 2. Strip combining marks (diacritics, U+0300–U+036F)
|
||||
* 3. Strip trademark symbols
|
||||
* 4. Lowercase
|
||||
* 5. Replace any non-alphanumeric run with a single hyphen
|
||||
* 6. Trim leading/trailing hyphens
|
||||
*
|
||||
* Examples:
|
||||
* "X²" → "x"
|
||||
* "Lex Luthor: Drop of Doom" → "lex-luthor-drop-of-doom"
|
||||
* "Catwoman's Whip" → "catwoman-s-whip"
|
||||
* "Façade" → "facade"
|
||||
* "Batman™ The Ride" → "batman-the-ride"
|
||||
*/
|
||||
export function slugifyRideName(name: string): string {
|
||||
return name
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[™®©]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Minimal structured warn-logger for scrapers. Matches the backend's
|
||||
* `${ISO} [WARN] [tag] msg key=value...` shape so warns from these files
|
||||
* grep alongside backend/src/log.ts output. Lives here (not in backend/)
|
||||
* because lib/scrapers/ is imported by both backend and Next.js code —
|
||||
* importing backend's log would cross a layering boundary.
|
||||
*/
|
||||
|
||||
type Meta = Record<string, unknown>;
|
||||
|
||||
export function scraperWarn(tag: string, msg: string, meta?: Meta): void {
|
||||
const parts: string[] = [];
|
||||
if (meta) {
|
||||
for (const [k, v] of Object.entries(meta)) {
|
||||
if (v === undefined) continue;
|
||||
const s = typeof v === "string" ? v : JSON.stringify(v);
|
||||
parts.push(`${k}=${s}`);
|
||||
}
|
||||
}
|
||||
const tail = parts.length ? " " + parts.join(" ") : "";
|
||||
console.warn(`${new Date().toISOString()} [WARN] [${tag}] ${msg}${tail}`);
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { isCoasterMatch } from "../coaster-match";
|
||||
import { scraperWarn } from "./log";
|
||||
|
||||
const BASE = "https://queue-times.com/parks";
|
||||
|
||||
@@ -19,12 +20,21 @@ const HEADERS = {
|
||||
};
|
||||
|
||||
export interface LiveRide {
|
||||
/** Stable Queue-Times ride ID — survives renames, used as the history key. */
|
||||
qtRideId: number;
|
||||
name: string;
|
||||
isOpen: boolean;
|
||||
waitMinutes: number;
|
||||
lastUpdated: string; // ISO 8601
|
||||
/** True when the ride name appears in the RCDB coaster list for this park. */
|
||||
isCoaster: boolean;
|
||||
/** True when the ride supports Fast Lane (from the Six Flags /wait-times endpoint).
|
||||
* Set by the rides route, not the Queue-Times scraper. */
|
||||
hasFastLane?: boolean;
|
||||
/** Current Fast Lane wait in minutes; null = no data / walk-on. Set by the rides route. */
|
||||
fastLaneMinutes?: number | null;
|
||||
/** URL-safe slug derived from name. Set by the rides route. */
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
export interface LiveRidesResult {
|
||||
@@ -80,7 +90,14 @@ export async function fetchLiveRides(
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
} as RequestInit & { next: { revalidate: number } });
|
||||
|
||||
if (!res.ok) return null;
|
||||
if (!res.ok) {
|
||||
scraperWarn("queuetimes", "fetchLiveRides non-OK response", {
|
||||
queueTimesId,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const json = (await res.json()) as QTResponse;
|
||||
|
||||
@@ -90,6 +107,7 @@ export async function fetchLiveRides(
|
||||
for (const r of land.rides ?? []) {
|
||||
if (!r.name) continue;
|
||||
rides.push({
|
||||
qtRideId: r.id,
|
||||
name: r.name,
|
||||
isOpen: r.is_open,
|
||||
waitMinutes: r.wait_time ?? 0,
|
||||
@@ -103,6 +121,7 @@ export async function fetchLiveRides(
|
||||
for (const r of json.rides ?? []) {
|
||||
if (!r.name) continue;
|
||||
rides.push({
|
||||
qtRideId: r.id,
|
||||
name: r.name,
|
||||
isOpen: r.is_open,
|
||||
waitMinutes: r.wait_time ?? 0,
|
||||
@@ -120,7 +139,13 @@ export async function fetchLiveRides(
|
||||
});
|
||||
|
||||
return { rides, fetchedAt: new Date().toISOString() };
|
||||
} catch {
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
scraperWarn("queuetimes", "fetchLiveRides threw", {
|
||||
queueTimesId,
|
||||
name: e.name,
|
||||
err: e.message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* 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, "");
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Six Flags live wait-times scraper — Fast Lane data.
|
||||
*
|
||||
* API: https://d18car1k0ff81h.cloudfront.net/wait-times/park/{apiId}
|
||||
* Sibling of the operating-hours endpoint in sixflags.ts. Exposes both a
|
||||
* regular and a Fast Lane wait per ride. We only consume the Fast Lane side;
|
||||
* regular waits + open status keep coming from Queue-Times.
|
||||
*
|
||||
* The response has no isOpen field, so Fast Lane numbers are joined onto the
|
||||
* Queue-Times ride list by name (see lookupFastLane) and gated on the
|
||||
* Queue-Times open status by the caller.
|
||||
*/
|
||||
|
||||
import { normalizeForMatch } from "../coaster-match";
|
||||
import { scraperWarn } from "./log";
|
||||
|
||||
const WAIT_TIMES_BASE = "https://d18car1k0ff81h.cloudfront.net/wait-times/park";
|
||||
|
||||
// Conjunctions that join two ride names rather than extend one subtitle —
|
||||
// kept in sync with coaster-match.ts so the prefix match stays symmetric.
|
||||
const CONJUNCTIONS = new Set(["y", "and", "&", "with", "de", "del", "e", "et"]);
|
||||
|
||||
const HEADERS = {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
Accept: "application/json",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
Referer: "https://www.sixflags.com/",
|
||||
};
|
||||
|
||||
interface WTWaittime {
|
||||
createdDateTime: string; // "" when no data
|
||||
waitTime: number;
|
||||
}
|
||||
|
||||
interface WTRideDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
isFastLane: boolean;
|
||||
regularWaittime?: WTWaittime;
|
||||
fastlaneWaittime?: WTWaittime;
|
||||
fimsId: string;
|
||||
}
|
||||
|
||||
interface WTVenue {
|
||||
venueId: number;
|
||||
venueName: string;
|
||||
details?: WTRideDetail[];
|
||||
}
|
||||
|
||||
export interface WTResponse {
|
||||
parkId?: number;
|
||||
parkName?: string;
|
||||
venues?: WTVenue[];
|
||||
}
|
||||
|
||||
interface FastLaneEntry {
|
||||
norm: string;
|
||||
compact: string;
|
||||
isFastLane: boolean;
|
||||
/** Current regular wait in minutes from Six Flags; null when the endpoint has no data. */
|
||||
regularMinutes: number | null;
|
||||
/** Current Fast Lane wait in minutes; null when the endpoint has no data. */
|
||||
fastLaneMinutes: number | null;
|
||||
}
|
||||
|
||||
export interface FastLaneResult {
|
||||
entries: FastLaneEntry[];
|
||||
/** ISO timestamp of when we fetched the data. */
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw /wait-times response into Fast Lane entries. Pure and
|
||||
* network-free so it can be unit tested. Returns null when no ride rows
|
||||
* are present.
|
||||
*/
|
||||
export function parseWaitTimes(json: WTResponse): FastLaneResult | null {
|
||||
const entries: FastLaneEntry[] = [];
|
||||
for (const venue of (json.venues ?? []).filter((v) => v.venueName === "Rides")) {
|
||||
for (const d of venue.details ?? []) {
|
||||
if (!d.name) continue;
|
||||
const norm = normalizeForMatch(d.name);
|
||||
entries.push({
|
||||
norm,
|
||||
compact: norm.replace(/\s/g, ""),
|
||||
isFastLane: Boolean(d.isFastLane),
|
||||
regularMinutes: d.regularWaittime?.createdDateTime
|
||||
? d.regularWaittime.waitTime
|
||||
: null,
|
||||
fastLaneMinutes: d.fastlaneWaittime?.createdDateTime
|
||||
? d.fastlaneWaittime.waitTime
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return { entries, fetchedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Fast Lane wait times for a park. Returns null on any failure
|
||||
* (network/parse/timeout) or when the response carries no rides — same
|
||||
* contract as fetchLiveRides.
|
||||
*
|
||||
* Pass revalidate (seconds) to control Next.js ISR cache lifetime.
|
||||
*/
|
||||
export async function fetchFastLaneWaits(
|
||||
apiId: number,
|
||||
revalidate = 300,
|
||||
): Promise<FastLaneResult | null> {
|
||||
const url = `${WAIT_TIMES_BASE}/${apiId}`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: HEADERS,
|
||||
next: { revalidate },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
} as RequestInit & { next: { revalidate: number } });
|
||||
|
||||
if (!res.ok) {
|
||||
scraperWarn("sixflags-waittimes", "fetchFastLaneWaits non-OK response", {
|
||||
apiId,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseWaitTimes((await res.json()) as WTResponse);
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
scraperWarn("sixflags-waittimes", "fetchFastLaneWaits threw", {
|
||||
apiId,
|
||||
name: e.name,
|
||||
err: e.message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Six Flags wait-times row for a ride by name. Mirrors the
|
||||
* isCoasterMatch strategy (exact normalized → compact ≥5 → prefix ≥5 with
|
||||
* conjunction guard) so Queue-Times and Six Flags name conventions line up.
|
||||
*
|
||||
* Returns both the regular and Fast Lane wait when a match exists, or null
|
||||
* when no ride matches. (Function name is historical — it originally only
|
||||
* exposed Fast Lane data.)
|
||||
*/
|
||||
export function lookupFastLane(
|
||||
rideName: string,
|
||||
result: FastLaneResult,
|
||||
): { hasFastLane: boolean; fastLaneMinutes: number | null; regularMinutes: number | null } | null {
|
||||
const norm = normalizeForMatch(rideName);
|
||||
const compact = norm.replace(/\s/g, "");
|
||||
|
||||
let match: FastLaneEntry | undefined = result.entries.find((e) => e.norm === norm);
|
||||
|
||||
if (!match) {
|
||||
for (const e of result.entries) {
|
||||
// Compact comparison
|
||||
if (compact.length >= 5 && e.compact === compact) {
|
||||
match = e;
|
||||
break;
|
||||
}
|
||||
// Prefix comparison
|
||||
const shorter = norm.length <= e.norm.length ? norm : e.norm;
|
||||
const longer = norm.length <= e.norm.length ? e.norm : norm;
|
||||
if (shorter.length >= 5 && longer.startsWith(shorter)) {
|
||||
const nextWord = longer.slice(shorter.length).trim().split(" ")[0];
|
||||
if (!CONJUNCTIONS.has(nextWord)) {
|
||||
match = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!match) return null;
|
||||
return {
|
||||
hasFastLane: match.isFastLane,
|
||||
fastLaneMinutes: match.fastLaneMinutes,
|
||||
regularMinutes: match.regularMinutes,
|
||||
};
|
||||
}
|
||||
+38
-14
@@ -1,15 +1,14 @@
|
||||
/**
|
||||
* Six Flags scraper — calls the internal CloudFront operating-hours API directly.
|
||||
* Six Flags API client — calls the internal CloudFront operating-hours API.
|
||||
*
|
||||
* API: https://d18car1k0ff81h.cloudfront.net/operating-hours/park/{apiId}?date=YYYYMM
|
||||
* Returns full month data in one request — no browser needed.
|
||||
*
|
||||
* Each park has a numeric API ID that must be discovered first (see scripts/discover.ts).
|
||||
* Once stored in the DB, this scraper never touches a browser again.
|
||||
* Returns full month data in one request.
|
||||
*
|
||||
* Rate limiting: on 429/503, exponential backoff (30s → 60s → 120s), MAX_RETRIES attempts.
|
||||
*/
|
||||
|
||||
import { scraperWarn } from "./log";
|
||||
|
||||
const API_BASE = "https://d18car1k0ff81h.cloudfront.net/operating-hours/park";
|
||||
const MAX_RETRIES = 3;
|
||||
const BASE_BACKOFF_MS = 30_000;
|
||||
@@ -194,9 +193,18 @@ export async function fetchToday(apiId: number, revalidate?: number): Promise<Da
|
||||
try {
|
||||
const url = `${API_BASE}/${apiId}`;
|
||||
const raw = await fetchApi(url, 0, 0, revalidate);
|
||||
if (!raw.dates.length) return null;
|
||||
if (!raw.dates.length) {
|
||||
scraperWarn("sixflags", "fetchToday empty dates array", { apiId });
|
||||
return null;
|
||||
}
|
||||
return parseApiDay(raw.dates[0]);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
scraperWarn("sixflags", "fetchToday threw", {
|
||||
apiId,
|
||||
name: e.name,
|
||||
err: e.message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -227,11 +235,22 @@ export async function scrapeRidesForDay(
|
||||
let raw: ApiResponse;
|
||||
try {
|
||||
raw = await scrapeMonthRaw(apiId, year, month, revalidate);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
scraperWarn("sixflags", "scrapeRidesForDay scrapeMonthRaw threw", {
|
||||
apiId,
|
||||
year,
|
||||
month,
|
||||
name: e.name,
|
||||
err: e.message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!raw.dates.length) return null;
|
||||
if (!raw.dates.length) {
|
||||
scraperWarn("sixflags", "scrapeRidesForDay empty dates array", { apiId, year, month });
|
||||
return null;
|
||||
}
|
||||
|
||||
// The API uses "MM/DD/YYYY" internally.
|
||||
const [, mm, dd] = dateIso.split("-");
|
||||
@@ -263,8 +282,15 @@ export async function scrapeRidesForDay(
|
||||
const nextRaw = await scrapeMonthRaw(apiId, nextYear, nextMonth, revalidate);
|
||||
const nextSorted = [...nextRaw.dates].sort((a, b) => a.date.localeCompare(b.date));
|
||||
dayData = nextSorted.find((d) => !d.isParkClosed) ?? nextSorted[0];
|
||||
} catch {
|
||||
// If the next month fetch fails, we simply have no fallback data.
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
scraperWarn("sixflags", "scrapeRidesForDay next-month fallback threw", {
|
||||
apiId,
|
||||
year: nextYear,
|
||||
month: nextMonth,
|
||||
name: e.name,
|
||||
err: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,7 +335,6 @@ export async function scrapeRidesForDay(
|
||||
|
||||
/**
|
||||
* Fetch operating hours for an entire month in a single API call.
|
||||
* apiId must be pre-discovered via scripts/discover.ts.
|
||||
*/
|
||||
export async function scrapeMonth(
|
||||
apiId: number,
|
||||
@@ -325,8 +350,7 @@ export async function scrapeMonth(
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch park info for a given API ID (used during discovery to identify park type).
|
||||
* Uses the current month so there's always some data.
|
||||
* Fetch park info for a given API ID. Uses the current month so there's always some data.
|
||||
*/
|
||||
export async function fetchParkInfo(
|
||||
apiId: number
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface Park {
|
||||
id: string;
|
||||
apiId: number;
|
||||
name: string;
|
||||
shortName: string;
|
||||
chain: "sixflags" | string;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Format a Date as YYYY-MM-DD in an IANA timezone.
|
||||
*
|
||||
* Uses "en-CA" because that locale natively produces ISO-style dates,
|
||||
* so we don't have to reassemble parts.
|
||||
*/
|
||||
export function formatLocalDate(d: Date, tz: string): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Date as HH:MM (24-hour) in an IANA timezone.
|
||||
*/
|
||||
export function formatLocalTime(d: Date, tz: string): string {
|
||||
const parts = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: tz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(d);
|
||||
const h = parts.find((p) => p.type === "hour")?.value ?? "00";
|
||||
const m = parts.find((p) => p.type === "minute")?.value ?? "00";
|
||||
return `${h}:${m}`;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface DayData {
|
||||
isOpen: boolean;
|
||||
hoursLabel: string | null;
|
||||
specialType: string | null;
|
||||
}
|
||||
+2
-4
@@ -2,17 +2,15 @@ import type { NextConfig } from "next";
|
||||
|
||||
const CSP = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline'", // Next.js requires unsafe-inline for hydration
|
||||
"script-src 'self' 'unsafe-inline' https://tracking.thewrightserver.net", // 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",
|
||||
"connect-src 'self' https://queue-times.com https://tracking.thewrightserver.net",
|
||||
"frame-ancestors 'none'",
|
||||
].join("; ");
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// better-sqlite3 is a native module — must not be bundled by webpack
|
||||
serverExternalPackages: ["better-sqlite3"],
|
||||
output: "standalone",
|
||||
|
||||
async headers() {
|
||||
|
||||
Generated
+390
-765
File diff suppressed because it is too large
Load Diff
+4
-8
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "sixflags-super-calendar",
|
||||
"name": "thoosie-calendar",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -7,27 +7,23 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"scrape": "tsx scripts/scrape.ts",
|
||||
"scrape:force": "tsx scripts/scrape.ts --rescrape",
|
||||
"discover": "tsx scripts/discover.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"debug": "tsx scripts/debug.ts",
|
||||
"test": "tsx --test tests/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"next": "^15.3.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^3.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^15.3.0",
|
||||
"playwright": "^1.59.1",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
|
||||
+2
-13
@@ -9,7 +9,6 @@
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { openDb, getApiId } from "../lib/db";
|
||||
import { PARKS } from "../lib/parks";
|
||||
import { scrapeMonthRaw } from "../lib/scrapers/sixflags";
|
||||
|
||||
@@ -52,16 +51,6 @@ async function main() {
|
||||
const month = parseInt(monthStr);
|
||||
const day = parseInt(dayStr);
|
||||
|
||||
const db = openDb();
|
||||
const apiId = getApiId(db, park.id);
|
||||
db.close();
|
||||
|
||||
if (apiId === null) {
|
||||
console.error(`No API ID found for ${park.name} — run: npm run discover`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Collect all output so we can write it to a file as well
|
||||
const lines: string[] = [];
|
||||
const out = (...args: string[]) => {
|
||||
const line = args.join(" ");
|
||||
@@ -70,13 +59,13 @@ async function main() {
|
||||
};
|
||||
|
||||
out(`Park : ${park.name} (${park.id})`);
|
||||
out(`API ID : ${apiId}`);
|
||||
out(`API ID : ${park.apiId}`);
|
||||
out(`Date : ${dateStr}`);
|
||||
out(`Fetched : ${new Date().toISOString()}`);
|
||||
out("");
|
||||
out(`Fetching ${year}-${String(month).padStart(2, "0")} from API...`);
|
||||
|
||||
const raw = await scrapeMonthRaw(apiId, year, month);
|
||||
const raw = await scrapeMonthRaw(park.apiId, year, month);
|
||||
|
||||
const targetDate = `${String(month).padStart(2, "0")}/${String(day).padStart(2, "0")}/${year}`;
|
||||
const dayData = raw.dates.find((d) => d.date === targetDate);
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
/**
|
||||
* One-time discovery script — finds the CloudFront API ID for each park.
|
||||
*
|
||||
* Run this once before using scrape.ts:
|
||||
* npx tsx scripts/discover.ts
|
||||
*
|
||||
* For each park in the registry it:
|
||||
* 1. Opens the park's hours page in a headless browser
|
||||
* 2. Intercepts all calls to the operating-hours CloudFront API
|
||||
* 3. Identifies the main theme park ID (filters out water parks, safari, etc.)
|
||||
* 4. Stores the ID in the database
|
||||
*
|
||||
* Re-running is safe — already-discovered parks are skipped.
|
||||
*/
|
||||
|
||||
import { chromium } from "playwright";
|
||||
import { openDb, getApiId, setApiId, type DbInstance } from "../lib/db";
|
||||
import { PARKS } from "../lib/parks";
|
||||
import { fetchParkInfo, isMainThemePark } from "../lib/scrapers/sixflags";
|
||||
import { readParkMeta, writeParkMeta, defaultParkMeta } from "../lib/park-meta";
|
||||
|
||||
const CLOUDFRONT_PATTERN = /operating-hours\/park\/(\d+)/;
|
||||
|
||||
async function discoverParkId(slug: string): Promise<number | null> {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
locale: "en-US",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
const capturedIds = new Set<number>();
|
||||
page.on("request", (req) => {
|
||||
const match = req.url().match(CLOUDFRONT_PATTERN);
|
||||
if (match) capturedIds.add(parseInt(match[1]));
|
||||
});
|
||||
|
||||
await page
|
||||
.goto(`https://www.sixflags.com/${slug}/park-hours?date=2026-05-01`, {
|
||||
waitUntil: "networkidle",
|
||||
timeout: 30_000,
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
await context.close();
|
||||
|
||||
if (capturedIds.size === 0) return null;
|
||||
|
||||
// Check each captured ID — pick the main theme park (not water park / safari)
|
||||
for (const id of capturedIds) {
|
||||
const info = await fetchParkInfo(id);
|
||||
if (info && isMainThemePark(info.parkName)) {
|
||||
console.log(
|
||||
` → ID ${id} | ${info.parkAbbreviation} | ${info.parkName}`
|
||||
);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return the lowest ID (usually the main park)
|
||||
const fallback = Math.min(...capturedIds);
|
||||
console.log(` → fallback to lowest ID: ${fallback}`);
|
||||
return fallback;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
function purgeRemovedParks(db: DbInstance) {
|
||||
const knownIds = new Set(PARKS.map((p) => p.id));
|
||||
|
||||
const staleParkIds = (
|
||||
db.prepare("SELECT DISTINCT park_id FROM park_api_ids").all() as { park_id: string }[]
|
||||
)
|
||||
.map((r) => r.park_id)
|
||||
.filter((id) => !knownIds.has(id));
|
||||
|
||||
if (staleParkIds.length === 0) return;
|
||||
|
||||
console.log(`\nRemoving ${staleParkIds.length} park(s) no longer in registry:`);
|
||||
for (const parkId of staleParkIds) {
|
||||
const days = (
|
||||
db.prepare("SELECT COUNT(*) AS n FROM park_days WHERE park_id = ?").get(parkId) as { n: number }
|
||||
).n;
|
||||
db.prepare("DELETE FROM park_days WHERE park_id = ?").run(parkId);
|
||||
db.prepare("DELETE FROM park_api_ids WHERE park_id = ?").run(parkId);
|
||||
console.log(` removed ${parkId} (${days} day rows deleted)`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const db = openDb();
|
||||
|
||||
purgeRemovedParks(db);
|
||||
|
||||
for (const park of PARKS) {
|
||||
const existing = getApiId(db, park.id);
|
||||
if (existing !== null) {
|
||||
console.log(`${park.name}: already known (API ID ${existing}) — skip`);
|
||||
continue;
|
||||
}
|
||||
|
||||
process.stdout.write(`${park.name} (${park.slug})... `);
|
||||
|
||||
try {
|
||||
const apiId = await discoverParkId(park.slug);
|
||||
if (apiId === null) {
|
||||
console.log("FAILED — no API IDs captured");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch full info to store name/abbreviation
|
||||
const info = await fetchParkInfo(apiId);
|
||||
setApiId(db, park.id, apiId, info?.parkAbbreviation, info?.parkName);
|
||||
} catch (err) {
|
||||
console.log(`ERROR: ${err}`);
|
||||
}
|
||||
|
||||
// Small delay between parks to be polite
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
|
||||
// ── Ensure park-meta.json has a skeleton entry for every park ────────────
|
||||
// Users fill in rcdb_id manually; scrape.ts populates coasters[] from RCDB.
|
||||
const meta = readParkMeta();
|
||||
let metaChanged = false;
|
||||
|
||||
for (const park of PARKS) {
|
||||
if (!meta[park.id]) {
|
||||
meta[park.id] = defaultParkMeta();
|
||||
metaChanged = true;
|
||||
}
|
||||
}
|
||||
// Remove entries for parks no longer in the registry
|
||||
for (const id of Object.keys(meta)) {
|
||||
if (!PARKS.find((p) => p.id === id)) {
|
||||
delete meta[id];
|
||||
metaChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (metaChanged) {
|
||||
writeParkMeta(meta);
|
||||
console.log("\nUpdated data/park-meta.json");
|
||||
console.log(" → Set rcdb_id for each park to enable the coaster filter.");
|
||||
console.log(" Find a park's RCDB ID from: https://rcdb.com (the number in the URL).");
|
||||
}
|
||||
|
||||
// Print summary
|
||||
console.log("\n── Discovered IDs ──");
|
||||
for (const park of PARKS) {
|
||||
const id = getApiId(db, park.id);
|
||||
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();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Nightly scraper scheduler — runs inside the Docker scraper service.
|
||||
#
|
||||
# Behaviour:
|
||||
# 1. Runs an initial scrape immediately on container start.
|
||||
# 2. Sleeps until 3:00 AM (container timezone, set via TZ env var).
|
||||
# 3. Runs the scraper, then sleeps until the next 3:00 AM, forever.
|
||||
#
|
||||
# Timezone: set TZ in the scraper service environment to control when
|
||||
# "3am" is (e.g. TZ=America/New_York). Defaults to UTC if unset.
|
||||
|
||||
log() {
|
||||
echo "[scheduler] $(date '+%Y-%m-%d %H:%M %Z') — $*"
|
||||
}
|
||||
|
||||
run_scrape() {
|
||||
log "Starting scrape"
|
||||
if npm run scrape; then
|
||||
log "Scrape completed"
|
||||
else
|
||||
log "Scrape failed — will retry at next scheduled time"
|
||||
fi
|
||||
}
|
||||
|
||||
seconds_until_3am() {
|
||||
now=$(date +%s)
|
||||
# Try today's 3am first; if already past, use tomorrow's.
|
||||
target=$(date -d "today 03:00" +%s)
|
||||
if [ "$now" -ge "$target" ]; then
|
||||
target=$(date -d "tomorrow 03:00" +%s)
|
||||
fi
|
||||
echo $((target - now))
|
||||
}
|
||||
|
||||
# ── Run immediately on startup ────────────────────────────────────────────────
|
||||
run_scrape
|
||||
|
||||
# ── Nightly loop ──────────────────────────────────────────────────────────────
|
||||
while true; do
|
||||
wait=$(seconds_until_3am)
|
||||
next=$(date -d "now + ${wait} seconds" '+%Y-%m-%d %H:%M %Z')
|
||||
log "Next scrape in $((wait / 3600))h $((( wait % 3600) / 60))m (${next})"
|
||||
sleep "$wait"
|
||||
run_scrape
|
||||
done
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* Scrape job — fetches 2026 operating hours for all parks from the Six Flags API.
|
||||
*
|
||||
* Prerequisite: run `npm run discover` first to populate API IDs.
|
||||
*
|
||||
* npm run scrape — skips months scraped within the last 7 days
|
||||
* npm run scrape:force — re-scrapes everything
|
||||
*/
|
||||
|
||||
import { openDb, upsertDay, getApiId, isMonthScraped } from "../lib/db";
|
||||
import { PARKS } from "../lib/parks";
|
||||
import { scrapeMonth, fetchToday, RateLimitError } from "../lib/scrapers/sixflags";
|
||||
import { readParkMeta, writeParkMeta, areCoastersStale } from "../lib/park-meta";
|
||||
import { scrapeRcdbCoasters } from "../lib/scrapers/rcdb";
|
||||
|
||||
const YEAR = 2026;
|
||||
const MONTHS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
||||
const DELAY_MS = 1000;
|
||||
const FORCE = process.argv.includes("--rescrape");
|
||||
|
||||
async function sleep(ms: number) {
|
||||
return new Promise<void>((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const db = openDb();
|
||||
|
||||
const ready = PARKS.filter((p) => getApiId(db, p.id) !== null);
|
||||
const needsDiscovery = PARKS.filter((p) => getApiId(db, p.id) === null);
|
||||
|
||||
if (needsDiscovery.length > 0) {
|
||||
console.log(
|
||||
`⚠ ${needsDiscovery.length} park(s) need discovery first: ${needsDiscovery.map((p) => p.id).join(", ")}\n`
|
||||
);
|
||||
}
|
||||
|
||||
if (ready.length === 0) {
|
||||
console.log("No parks ready — run: npm run discover");
|
||||
db.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Scraping ${YEAR} — ${ready.length} parks\n`);
|
||||
|
||||
let totalFetched = 0;
|
||||
let totalSkipped = 0;
|
||||
let totalErrors = 0;
|
||||
|
||||
for (const park of ready) {
|
||||
const apiId = getApiId(db, park.id)!;
|
||||
const label = park.shortName.padEnd(22);
|
||||
|
||||
let openDays = 0;
|
||||
let fetched = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
|
||||
process.stdout.write(` ${label} `);
|
||||
|
||||
for (const month of MONTHS) {
|
||||
if (!FORCE && isMonthScraped(db, park.id, YEAR, month)) {
|
||||
process.stdout.write("·");
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const days = await scrapeMonth(apiId, YEAR, month);
|
||||
db.transaction(() => {
|
||||
for (const d of days) upsertDay(db, park.id, d.date, d.isOpen, d.hoursLabel, d.specialType);
|
||||
})();
|
||||
openDays += days.filter((d) => d.isOpen).length;
|
||||
fetched++;
|
||||
process.stdout.write("█");
|
||||
if (fetched + skipped + errors < MONTHS.length) await sleep(DELAY_MS);
|
||||
} catch (err) {
|
||||
if (err instanceof RateLimitError) {
|
||||
process.stdout.write("✗");
|
||||
} else {
|
||||
process.stdout.write("✗");
|
||||
console.error(`\n error: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
totalFetched += fetched;
|
||||
totalSkipped += skipped;
|
||||
totalErrors += errors;
|
||||
|
||||
if (errors > 0) {
|
||||
console.log(` ${errors} error(s)`);
|
||||
} else if (skipped === MONTHS.length) {
|
||||
console.log(" up to date");
|
||||
} else {
|
||||
console.log(` ${openDays} open days`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n ${totalFetched} fetched ${totalSkipped} skipped ${totalErrors} errors`);
|
||||
if (totalErrors > 0) console.log(" Re-run to retry failed months.");
|
||||
|
||||
// ── Today scrape (always fresh — dateless endpoint returns current day) ────
|
||||
console.log("\n── Today's data ──");
|
||||
for (const park of ready) {
|
||||
const apiId = getApiId(db, park.id)!;
|
||||
process.stdout.write(` ${park.shortName.padEnd(22)} `);
|
||||
try {
|
||||
const today = await fetchToday(apiId);
|
||||
if (today) {
|
||||
upsertDay(db, park.id, today.date, today.isOpen, today.hoursLabel, today.specialType);
|
||||
console.log(today.isOpen ? `open ${today.hoursLabel ?? ""}` : "closed");
|
||||
} else {
|
||||
console.log("no data");
|
||||
}
|
||||
} catch {
|
||||
console.log("error");
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
db.close();
|
||||
|
||||
// ── RCDB coaster scrape (30-day staleness) ────────────────────────────────
|
||||
const meta = readParkMeta();
|
||||
const rcdbParks = PARKS.filter((p) => {
|
||||
const entry = meta[p.id];
|
||||
return entry?.rcdb_id && (FORCE || areCoastersStale(entry));
|
||||
});
|
||||
|
||||
if (rcdbParks.length === 0) {
|
||||
console.log("\nCoaster data up to date.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n── RCDB coaster scrape — ${rcdbParks.length} park(s) ──`);
|
||||
|
||||
for (const park of rcdbParks) {
|
||||
const entry = meta[park.id];
|
||||
const rcdbId = entry.rcdb_id!;
|
||||
process.stdout.write(` ${park.shortName.padEnd(30)} `);
|
||||
|
||||
const coasters = await scrapeRcdbCoasters(rcdbId);
|
||||
if (coasters === null) {
|
||||
console.log("FAILED");
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.coasters = coasters;
|
||||
entry.coasters_scraped_at = new Date().toISOString();
|
||||
console.log(`${coasters.length} coasters`);
|
||||
|
||||
// Polite delay between RCDB requests
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
|
||||
writeParkMeta(meta);
|
||||
console.log(" Saved to data/park-meta.json");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Fast Lane name-join tests.
|
||||
*
|
||||
* The Six Flags /wait-times endpoint and Queue-Times use slightly different
|
||||
* ride name conventions, so Fast Lane waits are joined onto Queue-Times rides
|
||||
* by normalized name. These cases lock that join behaviour.
|
||||
*
|
||||
* Run with: npm test
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseWaitTimes, lookupFastLane } from "../lib/scrapers/sixflags-waittimes";
|
||||
import type { WTResponse } from "../lib/scrapers/sixflags-waittimes";
|
||||
|
||||
function ride(
|
||||
name: string,
|
||||
isFastLane: boolean,
|
||||
fastLaneMinutes: number | null,
|
||||
regularMinutes: number | null = 20,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
id: 1,
|
||||
name,
|
||||
isFastLane,
|
||||
regularWaittime:
|
||||
regularMinutes === null
|
||||
? { createdDateTime: "", waitTime: 0 }
|
||||
: { createdDateTime: "May 29, 2026 19:00:00", waitTime: regularMinutes },
|
||||
fastlaneWaittime:
|
||||
fastLaneMinutes === null
|
||||
? { createdDateTime: "", waitTime: 0 }
|
||||
: { createdDateTime: "May 29, 2026 19:00:00", waitTime: fastLaneMinutes },
|
||||
fimsId: "RIDE-906-00001",
|
||||
};
|
||||
}
|
||||
|
||||
function result(...rides: Record<string, unknown>[]) {
|
||||
const json: WTResponse = {
|
||||
parkId: 906,
|
||||
venues: [{ venueId: 1, venueName: "Rides", details: rides as never }],
|
||||
};
|
||||
const r = parseWaitTimes(json);
|
||||
assert.ok(r, "expected parseWaitTimes to return a result");
|
||||
return r;
|
||||
}
|
||||
|
||||
// ── Name joins across QT ↔ SF naming quirks ──────────────────────────────────
|
||||
|
||||
test("matches across trademark symbols, THE prefix, possessives", () => {
|
||||
const r = result(
|
||||
ride("Batman: The Ride", true, 5),
|
||||
ride("Riddler's Revenge", true, 10),
|
||||
ride("Apocalypse the Ride", true, 15),
|
||||
);
|
||||
|
||||
// Queue-Times-style names on the left should resolve to the SF entries.
|
||||
assert.deepEqual(lookupFastLane("BATMAN™ The Ride", r), {
|
||||
hasFastLane: true,
|
||||
fastLaneMinutes: 5,
|
||||
regularMinutes: 20,
|
||||
});
|
||||
assert.deepEqual(lookupFastLane("THE RIDDLER™'s Revenge", r), {
|
||||
hasFastLane: true,
|
||||
fastLaneMinutes: 10,
|
||||
regularMinutes: 20,
|
||||
});
|
||||
// Prefix match: "Apocalypse" is a prefix of "Apocalypse the Ride".
|
||||
assert.deepEqual(lookupFastLane("Apocalypse", r), {
|
||||
hasFastLane: true,
|
||||
fastLaneMinutes: 15,
|
||||
regularMinutes: 20,
|
||||
});
|
||||
});
|
||||
|
||||
test("a non-Fast-Lane ride resolves to hasFastLane: false", () => {
|
||||
const r = result(ride("Bucaneer", false, null));
|
||||
assert.deepEqual(lookupFastLane("Bucaneer", r), {
|
||||
hasFastLane: false,
|
||||
fastLaneMinutes: null,
|
||||
regularMinutes: 20,
|
||||
});
|
||||
});
|
||||
|
||||
test("empty fastlane createdDateTime yields fastLaneMinutes: null", () => {
|
||||
const r = result(ride("Batman: The Ride", true, null));
|
||||
assert.deepEqual(lookupFastLane("Batman: The Ride", r), {
|
||||
hasFastLane: true,
|
||||
fastLaneMinutes: null,
|
||||
regularMinutes: 20,
|
||||
});
|
||||
});
|
||||
|
||||
test("regular wait is surfaced when regularWaittime.createdDateTime is fresh", () => {
|
||||
const r = result(ride("Goliath", true, null, 35));
|
||||
assert.deepEqual(lookupFastLane("Goliath", r), {
|
||||
hasFastLane: true,
|
||||
fastLaneMinutes: null,
|
||||
regularMinutes: 35,
|
||||
});
|
||||
});
|
||||
|
||||
test("walk-on regular wait (0) is surfaced, not null", () => {
|
||||
const r = result(ride("Buccaneer", false, null, 0));
|
||||
assert.deepEqual(lookupFastLane("Buccaneer", r), {
|
||||
hasFastLane: false,
|
||||
fastLaneMinutes: null,
|
||||
regularMinutes: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("empty regular createdDateTime yields regularMinutes: null", () => {
|
||||
const r = result(ride("Tatsu", true, 10, null));
|
||||
assert.deepEqual(lookupFastLane("Tatsu", r), {
|
||||
hasFastLane: true,
|
||||
fastLaneMinutes: 10,
|
||||
regularMinutes: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("a ride absent from SF data returns null", () => {
|
||||
const r = result(ride("Apocalypse the Ride", true, 15));
|
||||
assert.equal(lookupFastLane("Some Other Coaster", r), null);
|
||||
});
|
||||
|
||||
test("conjunction guard: compound name does not match a single ride", () => {
|
||||
const r = result(ride("Joker", true, 25));
|
||||
// "Joker y Harley Quinn" is a different (compound) ride, not a Joker subtitle.
|
||||
assert.equal(lookupFastLane("Joker y Harley Quinn", r), null);
|
||||
});
|
||||
|
||||
test("parseWaitTimes returns null when no ride rows present", () => {
|
||||
assert.equal(parseWaitTimes({ parkId: 1, venues: [] }), null);
|
||||
assert.equal(
|
||||
parseWaitTimes({ parkId: 1, venues: [{ venueId: 9, venueName: "Restaurants", details: [] }] }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Outage detection tests for the ride detail page's Today chart.
|
||||
*
|
||||
* Pure unit tests against lib/outage.ts — no DOM, no Recharts.
|
||||
*
|
||||
* Run with: npm test
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { computeOutages, formatOutageDuration, outageLookup } from "../lib/outage";
|
||||
import type { OutageSample } from "../lib/outage";
|
||||
|
||||
/** Build a sample at the given UTC time (just a convenience). */
|
||||
function s(t: string, isOpen: boolean): OutageSample {
|
||||
return { recordedAt: `2026-05-30T${t}:00Z`, isOpen };
|
||||
}
|
||||
|
||||
// ── computeOutages ───────────────────────────────────────────────────────────
|
||||
|
||||
test("no outages when every sample is open", () => {
|
||||
const out = computeOutages([s("12:00", true), s("12:05", true), s("12:10", true)]);
|
||||
assert.deepEqual(out, []);
|
||||
});
|
||||
|
||||
test("single outage in the middle of the day → #1, duration matches", () => {
|
||||
// closed at 13:00, 13:05, 13:10; reopens at 13:15
|
||||
const out = computeOutages([
|
||||
s("12:55", true),
|
||||
s("13:00", false),
|
||||
s("13:05", false),
|
||||
s("13:10", false),
|
||||
s("13:15", true),
|
||||
s("13:20", true),
|
||||
]);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].n, 1);
|
||||
assert.equal(out[0].durationMin, 15);
|
||||
assert.equal(out[0].startISO, "2026-05-30T13:00:00Z");
|
||||
assert.equal(out[0].endISO, "2026-05-30T13:15:00Z");
|
||||
});
|
||||
|
||||
test("two separate outages → numbered 1 then 2 in chronological order", () => {
|
||||
const out = computeOutages([
|
||||
s("12:00", true),
|
||||
s("12:05", false), s("12:10", false),
|
||||
s("12:15", true),
|
||||
s("14:00", false), s("14:05", false), s("14:10", false),
|
||||
s("14:15", true),
|
||||
]);
|
||||
assert.equal(out.length, 2);
|
||||
assert.equal(out[0].n, 1);
|
||||
assert.equal(out[0].durationMin, 10);
|
||||
assert.equal(out[1].n, 2);
|
||||
assert.equal(out[1].durationMin, 15);
|
||||
});
|
||||
|
||||
test("outage at start of day is still #1", () => {
|
||||
const out = computeOutages([
|
||||
s("10:00", false), s("10:05", false),
|
||||
s("10:10", true),
|
||||
]);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].n, 1);
|
||||
assert.equal(out[0].durationMin, 10);
|
||||
});
|
||||
|
||||
test("outage that never reopens uses the last sample as the end", () => {
|
||||
const out = computeOutages([
|
||||
s("16:00", true),
|
||||
s("16:05", false), s("16:10", false), s("16:15", false),
|
||||
]);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].n, 1);
|
||||
// start 16:05 → end 16:15 → 10 min (in-progress at end of day)
|
||||
assert.equal(out[0].durationMin, 10);
|
||||
assert.equal(out[0].endISO, "2026-05-30T16:15:00Z");
|
||||
});
|
||||
|
||||
test("empty sample array → no outages", () => {
|
||||
assert.deepEqual(computeOutages([]), []);
|
||||
});
|
||||
|
||||
test("single closed sample → 0-minute outage, still numbered #1", () => {
|
||||
const out = computeOutages([s("12:00", true), s("12:05", false), s("12:10", true)]);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].n, 1);
|
||||
assert.equal(out[0].durationMin, 5);
|
||||
});
|
||||
|
||||
// ── outageLookup ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("outageLookup tags each closed sample's time label with its outage", () => {
|
||||
const samples = [
|
||||
s("12:00", true),
|
||||
s("12:05", false), s("12:10", false),
|
||||
s("12:15", true),
|
||||
s("14:00", false), s("14:05", false),
|
||||
s("14:10", true),
|
||||
];
|
||||
const outages = computeOutages(samples);
|
||||
const lookup = outageLookup(samples, outages);
|
||||
|
||||
// 12:05 and 12:10 belong to outage #1; 14:00 and 14:05 belong to #2.
|
||||
// The exact HH:MM labels depend on the runner's local timezone, so we
|
||||
// assert membership using the same TIME_FMT that outage.ts uses.
|
||||
const TIME_FMT = new Intl.DateTimeFormat([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const fmt = (iso: string) => TIME_FMT.format(new Date(iso));
|
||||
|
||||
assert.equal(lookup.get(fmt("2026-05-30T12:05:00Z"))?.n, 1);
|
||||
assert.equal(lookup.get(fmt("2026-05-30T12:10:00Z"))?.n, 1);
|
||||
assert.equal(lookup.get(fmt("2026-05-30T14:00:00Z"))?.n, 2);
|
||||
assert.equal(lookup.get(fmt("2026-05-30T14:05:00Z"))?.n, 2);
|
||||
// Open samples are not in the lookup.
|
||||
assert.equal(lookup.get(fmt("2026-05-30T12:00:00Z")), undefined);
|
||||
assert.equal(lookup.get(fmt("2026-05-30T14:10:00Z")), undefined);
|
||||
});
|
||||
|
||||
// ── formatOutageDuration ─────────────────────────────────────────────────────
|
||||
|
||||
test("formatOutageDuration renders minutes-only under 1 hour", () => {
|
||||
assert.equal(formatOutageDuration(0), "0m");
|
||||
assert.equal(formatOutageDuration(1), "1m");
|
||||
assert.equal(formatOutageDuration(47), "47m");
|
||||
assert.equal(formatOutageDuration(59), "59m");
|
||||
});
|
||||
|
||||
test("formatOutageDuration renders exact hours as 'Xh'", () => {
|
||||
assert.equal(formatOutageDuration(60), "1h");
|
||||
assert.equal(formatOutageDuration(120), "2h");
|
||||
});
|
||||
|
||||
test("formatOutageDuration renders hours + minutes", () => {
|
||||
assert.equal(formatOutageDuration(88), "1h 28m");
|
||||
assert.equal(formatOutageDuration(150), "2h 30m");
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Slug determinism tests. The slug is a URL-safe secondary key on the
|
||||
* `rides` table — same name must always produce the same slug.
|
||||
*
|
||||
* Run with: npm test
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { slugifyRideName } from "../lib/ride-slug";
|
||||
|
||||
const CASES: [name: string, expected: string][] = [
|
||||
["Goliath", "goliath"],
|
||||
["X²", "x"],
|
||||
["Lex Luthor: Drop of Doom", "lex-luthor-drop-of-doom"],
|
||||
["Catwoman's Whip", "catwoman-s-whip"],
|
||||
["Façade", "facade"],
|
||||
["Le Monstre", "le-monstre"],
|
||||
["Batman™ The Ride", "batman-the-ride"],
|
||||
["THE RIDDLER's Revenge", "the-riddler-s-revenge"],
|
||||
["Joker y Harley Quinn", "joker-y-harley-quinn"],
|
||||
["Apocalypse the Ride", "apocalypse-the-ride"],
|
||||
[" Leading and trailing ", "leading-and-trailing"],
|
||||
["123 Numeric", "123-numeric"],
|
||||
["!!!", ""],
|
||||
];
|
||||
|
||||
for (const [name, expected] of CASES) {
|
||||
test(`slugify "${name}" → "${expected}"`, () => {
|
||||
assert.equal(slugifyRideName(name), expected);
|
||||
});
|
||||
}
|
||||
|
||||
test("slug is idempotent — slugifying the result yields the same value", () => {
|
||||
for (const [name] of CASES) {
|
||||
const once = slugifyRideName(name);
|
||||
if (once === "") continue;
|
||||
assert.equal(slugifyRideName(once), once, `Expected idempotent slug for "${name}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test("same name always produces the same slug", () => {
|
||||
const name = "Twisted Cyclone";
|
||||
assert.equal(slugifyRideName(name), slugifyRideName(name));
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Timezone bucketing tests for ride wait samples.
|
||||
*
|
||||
* Samples are stored with UTC `recorded_at` and pre-bucketed `local_date`
|
||||
* + `local_time` columns in the park's IANA timezone. These columns are what
|
||||
* the aggregation queries group on, so the bucketing has to be DST-safe
|
||||
* across spring forward and fall back.
|
||||
*
|
||||
* Run with: npm test
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { formatLocalDate, formatLocalTime } from "../lib/timezone";
|
||||
|
||||
// ── Basic cases ──────────────────────────────────────────────────────────────
|
||||
|
||||
test("formatLocalDate produces YYYY-MM-DD in the target zone", () => {
|
||||
// 2026-05-29 17:00 UTC = 2026-05-29 13:00 ET = 2026-05-29 10:00 PT
|
||||
const d = new Date("2026-05-29T17:00:00Z");
|
||||
assert.equal(formatLocalDate(d, "America/New_York"), "2026-05-29");
|
||||
assert.equal(formatLocalDate(d, "America/Los_Angeles"), "2026-05-29");
|
||||
});
|
||||
|
||||
test("formatLocalDate rolls to previous day for late UTC times in west zones", () => {
|
||||
// 2026-05-30 04:00 UTC = 2026-05-29 21:00 PT
|
||||
const d = new Date("2026-05-30T04:00:00Z");
|
||||
assert.equal(formatLocalDate(d, "America/Los_Angeles"), "2026-05-29");
|
||||
assert.equal(formatLocalDate(d, "America/New_York"), "2026-05-30");
|
||||
});
|
||||
|
||||
test("formatLocalTime produces HH:MM in 24-hour format", () => {
|
||||
// 2026-05-29 23:30 UTC = 2026-05-29 19:30 ET = 2026-05-29 16:30 PT
|
||||
const d = new Date("2026-05-29T23:30:00Z");
|
||||
assert.equal(formatLocalTime(d, "America/New_York"), "19:30");
|
||||
assert.equal(formatLocalTime(d, "America/Los_Angeles"), "16:30");
|
||||
});
|
||||
|
||||
// ── DST: spring forward (2026-03-08 in US: 2 AM → 3 AM) ──────────────────────
|
||||
|
||||
test("spring forward: time before transition shows in standard offset", () => {
|
||||
// 2026-03-08 07:30 UTC = 2026-03-08 02:30 EST (before transition completes)
|
||||
// Actually: at 2026-03-08 07:00 UTC = 2026-03-08 03:00 EDT (after transition)
|
||||
// Use a clearly-before time:
|
||||
const before = new Date("2026-03-08T06:30:00Z"); // 01:30 EST
|
||||
assert.equal(formatLocalDate(before, "America/New_York"), "2026-03-08");
|
||||
assert.equal(formatLocalTime(before, "America/New_York"), "01:30");
|
||||
});
|
||||
|
||||
test("spring forward: time after transition shows in DST offset", () => {
|
||||
// 2026-03-08 07:30 UTC = 2026-03-08 03:30 EDT (DST in effect)
|
||||
const after = new Date("2026-03-08T07:30:00Z");
|
||||
assert.equal(formatLocalDate(after, "America/New_York"), "2026-03-08");
|
||||
assert.equal(formatLocalTime(after, "America/New_York"), "03:30");
|
||||
});
|
||||
|
||||
test("spring forward: local_date is consistent across the missing hour", () => {
|
||||
// The skipped hour is 02:00–03:00 EST. Samples bracketing it should still
|
||||
// bucket to the same local_date.
|
||||
const before = new Date("2026-03-08T06:30:00Z"); // 01:30 EST
|
||||
const after = new Date("2026-03-08T07:30:00Z"); // 03:30 EDT
|
||||
assert.equal(formatLocalDate(before, "America/New_York"), formatLocalDate(after, "America/New_York"));
|
||||
});
|
||||
|
||||
// ── DST: fall back (2026-11-01 in US: 2 AM → 1 AM) ────────────────────────────
|
||||
|
||||
test("fall back: time before transition shows in DST offset", () => {
|
||||
// 2026-11-01 05:30 UTC = 2026-11-01 01:30 EDT (before fall-back at 2 AM EDT)
|
||||
const beforeFallback = new Date("2026-11-01T05:30:00Z");
|
||||
assert.equal(formatLocalDate(beforeFallback, "America/New_York"), "2026-11-01");
|
||||
assert.equal(formatLocalTime(beforeFallback, "America/New_York"), "01:30");
|
||||
});
|
||||
|
||||
test("fall back: time after transition shows in standard offset", () => {
|
||||
// 2026-11-01 07:30 UTC = 2026-11-01 02:30 EST (after fall-back)
|
||||
const afterFallback = new Date("2026-11-01T07:30:00Z");
|
||||
assert.equal(formatLocalDate(afterFallback, "America/New_York"), "2026-11-01");
|
||||
assert.equal(formatLocalTime(afterFallback, "America/New_York"), "02:30");
|
||||
});
|
||||
|
||||
test("fall back: the same local hour repeats but local_date stays stable", () => {
|
||||
// 2026-11-01 05:30 UTC = 01:30 EDT
|
||||
// 2026-11-01 06:30 UTC = 01:30 EST (second occurrence of 01:30 — fall back)
|
||||
const first = new Date("2026-11-01T05:30:00Z");
|
||||
const second = new Date("2026-11-01T06:30:00Z");
|
||||
assert.equal(formatLocalTime(first, "America/New_York"), "01:30");
|
||||
assert.equal(formatLocalTime(second, "America/New_York"), "01:30");
|
||||
assert.equal(formatLocalDate(first, "America/New_York"), formatLocalDate(second, "America/New_York"));
|
||||
});
|
||||
|
||||
// ── Cross-zone: a single UTC moment buckets differently per park ─────────────
|
||||
|
||||
test("midnight UTC straddles the local-date boundary for west-coast parks", () => {
|
||||
const utcMidnight = new Date("2026-06-15T00:00:00Z");
|
||||
// Eastern: still 2026-06-14 20:00
|
||||
assert.equal(formatLocalDate(utcMidnight, "America/New_York"), "2026-06-14");
|
||||
// Pacific: 2026-06-14 17:00
|
||||
assert.equal(formatLocalDate(utcMidnight, "America/Los_Angeles"), "2026-06-14");
|
||||
});
|
||||
|
||||
test("Mountain and Central parks bucket distinctly during the late-evening hour", () => {
|
||||
// 2026-07-04 04:30 UTC
|
||||
// = 2026-07-03 21:30 MDT (UTC-6) → date 2026-07-03
|
||||
// = 2026-07-03 23:30 CDT (UTC-5) → date 2026-07-03
|
||||
const d = new Date("2026-07-04T04:30:00Z");
|
||||
assert.equal(formatLocalDate(d, "America/Denver"), "2026-07-03");
|
||||
assert.equal(formatLocalDate(d, "America/Chicago"), "2026-07-03");
|
||||
assert.equal(formatLocalTime(d, "America/Denver"), "22:30");
|
||||
assert.equal(formatLocalTime(d, "America/Chicago"), "23:30");
|
||||
});
|
||||
+1
-1
@@ -23,5 +23,5 @@
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "backend"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user