Compare commits

...

30 Commits

Author SHA1 Message Date
josh f87462385c docs: sync README and docs/ with current codebase
Build and Deploy / Lint, typecheck, test (push) Successful in 34s
Build and Deploy / Build & Push (push) Successful in 1m6s
Surfaces features that landed after the last big docs pass: per-ride
history pages, Fast Lane wait times, outage shading on the today chart,
Tier-5 wait-time sampler, production-hardening pieces (rate limiter,
structured logger, env validation, graceful shutdown), and the new
rides + ride_wait_samples tables. Also corrects the weather-delay rule
to match the "open" vs "closing" gate now in rides.ts.
2026-06-02 15:31:50 -04:00
josh 2e9cec0b56 fix: make Max wait readable in chart tooltip and legend
Build and Deploy / Lint, typecheck, test (push) Successful in 31s
Build and Deploy / Build & Push (push) Successful in 1m6s
The Max bar uses a dark fill with bright stroke for the outlined look,
but Recharts pulled tooltip/legend text color from the dark fill — making
the Max row unreadable against the dark tooltip background. Render the
tooltip via a custom content fn and recolor the legend's Max label so
both pick up the bright stroke color instead.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 09:38:20 -04:00
josh 0dc84c7597 fix: bypass Data Cache on live park/ride pages so navigation shows fresh data
Build and Deploy / Lint, typecheck, test (push) Successful in 33s
Build and Deploy / Build & Push (push) Successful in 1m15s
The ride detail and park pages fetched with `next: { revalidate: 60 }`,
which is stale-while-revalidate. After hours of no traffic the Data Cache
held a morning snapshot; the first click served that stale value and only
the second request (e.g. a browser refresh) got the just-revalidated
payload. The endpoint also bundles live state with chart history, so one
stale fetch made the whole page wrong.

Switch the live-data fetches to `cache: "no-store"`. The calendar-month
fetch keeps its 5-min ISR since operating hours change slowly.
2026-06-01 21:06:58 -04:00
josh 301ed797ea fix: suppress park-page weather-delay flag during wind-down too
Build and Deploy / Lint, typecheck, test (push) Successful in 30s
Build and Deploy / Build & Push (push) Successful in 1m15s
Same root cause as 52f7efd — the /rides endpoint also used
isWithinOperatingWindow, which includes the post-close buffer. Switch
to getOperatingStatus and gate the badge on status === "open" so the
park page matches the calendar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 20:54:05 -04:00
josh 52f7efd21a fix: suppress weather-delay flag during post-close wind-down
Build and Deploy / Lint, typecheck, test (push) Successful in 28s
Build and Deploy / Build & Push (push) Successful in 1m15s
Parks naturally wind down rides over the 1-hour buffer after their
scheduled close, so all-rides-closed in that window isn't a weather
delay — it's just closing time. Both the calendar UI badge and the
sampler's telemetry counter were misclassifying this.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 20:49:14 -04:00
josh e1657f07d7 fix: surface silent scraper failures and stop falsely claiming weather delay
Build and Deploy / Lint, typecheck, test (push) Successful in 33s
Build and Deploy / Build & Push (push) Successful in 1m4s
The homepage was flagging every park as weather delay because calendar.ts
collapsed "fetchLiveRides returned null" into the same openRides=0 bucket as
"all rides actually closed." Meanwhile every scraper (queuetimes, sixflags
operating-hours, sixflags wait-times) was swallowing non-OK responses and
exceptions silently, so logs gave no signal which upstream was failing or how.

Add a small scraperWarn helper that emits in the same shape as backend/log.ts
(without importing it — lib/scrapers is shared with the Next frontend). Use it
in all three scrapers to record HTTP status and error name+message before each
return null. Add parksSkipped to the tier-5 summary log so we can tell when the
openParks filter is rejecting everyone vs the fetcher silently failing.

Convert calendar.ts ridesCache to a discriminated union { kind: "ok" | "unknown" }.
Weather delay only fires on { kind: "ok", openRides: 0 }; unknown entries get
a 30s TTL so we recover quickly when upstream comes back and don't thunder-herd
in the meantime.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 20:28:25 -04:00
josh d43d8eba86 fix: keep outage labels in-frame + show Fast Lane line based on data
Build and Deploy / Lint, typecheck, test (push) Successful in 34s
Build and Deploy / Build & Push (push) Successful in 57s
Two refinements after the previous label change:

1. Outage labels rendered at position="top" were clipping against the
   chart's 8px top margin. Bumped to 24px so #N · Hh Mm sits above the
   band fully visible.

2. Fast Lane line was only rendered when the ride's metadata flag
   has_fast_lane was true. Some rides report Fast Lane waits without
   getting flagged, so we now also render the line whenever today's
   samples carry any non-null fastLaneMinutes — catches rides that are
   walk-on all day with a flat line at 0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 19:36:08 -04:00
josh b401f28fef fix: show outage duration in the chart label
Build and Deploy / Lint, typecheck, test (push) Successful in 33s
Build and Deploy / Build & Push (push) Successful in 1m0s
The outage marker now reads "#N · 1h 28m" instead of just "#N" so the
duration is visible at a glance without hovering. Positioned above the
band ("position: top") rather than inside it — when the label string is
wider than the band, Recharts' insideTop placement silently drops the
ReferenceArea rect; placing the label above sidesteps that.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 19:16:24 -04:00
josh e888261ed9 feat: prefer Six Flags regular waits, show Fast Lane at 0, mark outages on chart
Build and Deploy / Lint, typecheck, test (push) Successful in 35s
Build and Deploy / Build & Push (push) Successful in 1m8s
Three usability fixes after a day of using the ride detail page.

1. Six Flags is now the primary source for regular wait times. SF's
   /wait-times endpoint reports regular waits alongside Fast Lane, and it
   updates more promptly than Queue-Times around park-open. The sampler
   and the live /rides + ride-history routes all prefer SF's regularWaittime
   when its createdDateTime is non-empty; Queue-Times remains the fallback
   and the authoritative isOpen source.

2. The today chart's Fast Lane line now stays visible when its value is 0
   (walk-on). Y-axis bottom padding ensures the line sits clearly above the
   X-axis frame instead of being clipped against it. The tooltip shows
   "walk-on" instead of "0 min" for that case.

3. Outages are now explicit on the chart instead of just being gaps.
   computeOutages walks today's samples to find contiguous closed runs and
   numbers them chronologically. Each outage renders as a translucent pink
   ReferenceArea with a "#N" label. The custom tooltip detects when the
   cursor is over an outage span and shows "Outage #N — Hh Mm" (e.g.
   "Outage #2 — 1h 28m") in place of the wait/Fast Lane rows.

Includes a seed-test-samples.ts dev script for eyeballing the chart with
synthetic outage data.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 18:54:02 -04:00
josh 5d9daee627 refactor: production-essentials hardening pass
Build and Deploy / Lint, typecheck, test (push) Successful in 30s
Build and Deploy / Build & Push (push) Successful in 1m39s
Backend: structured logger, env-validated config, graceful SIGTERM/SIGINT
shutdown, per-IP rate limiter, per-tier scheduler concurrency latch, error
context on previously-silent catches, compiled-JS Dockerfile stage.

Frontend: lib/api.ts consolidates BACKEND_URL with lazy production-required
check, root + per-segment error.tsx / not-found.tsx / loading.tsx,
generateMetadata on park and ride pages, graceful fallback when backend is
unreachable, Plausible script gated on env vars.

Infra: CI runs lint + typecheck + tests on both packages before docker build,
compose adds healthchecks, log rotation, and memory limits; .env.example
documents every variable.

Cleanup: removed empty app/api/parks/ dir and 0-byte root parks.db, moved
wait-times-urls.txt into docs/, dropped an `as any` cast.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 10:17:52 -04:00
josh 6447db3008 refactor: store selected week in a cookie, not the URL
Build and Deploy / Build & Push (push) Successful in 1m7s
The home page no longer reads ?week=YYYY-MM-DD from the URL. Selected week
lives in the tcWeek cookie, set via a server action that revalidates the
home page so the next render reflects it. The URL stays at "/" regardless
of which week the user is viewing.

WeekNav prev/next/today buttons (and the arrow-key bindings) call the
server action directly — no router.refresh dance, no client-side cookie
write. BackToCalendarLink drops its localStorage-based href reconstruction
and just links to "/" since the cookie already remembers the right week
across navigations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:39:20 -04:00
josh 44d079efb9 fix: render today's wait chart in viewer's local time + close stale live state
Build and Deploy / Build & Push (push) Successful in 1m8s
Two related polish fixes for the ride detail page:

1. Wait-time chart x-axis now uses Intl.DateTimeFormat with no timezone
   argument, so an Eastern-time user viewing a Pacific park sees ET on
   the axis. Backend now sends recorded_at (UTC) alongside local_time.

2. Ride-history endpoint now applies the same operating-window gate the
   /rides route uses. Queue-Times keeps reporting yesterday's last wait
   with isOpen=true overnight, which made the "Right now" pill show a
   live wait time when the park was actually closed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:23:00 -04:00
josh 7c88a3e568 fix: only sample wait times within each park's operating window
Build and Deploy / Build & Push (push) Successful in 1m5s
Queue-Times keeps reporting yesterday's last wait with isOpen=true overnight,
so the per-ride open check wasn't enough — the sampler was recording phantom
"open" samples between close and the next morning's first refresh, padding
both wait-time averages and uptime% with stale data.

Add isWithinOperatingWindow gate (same check the /rides route uses) so the
sampler only runs during the park's actual hours plus the 1-hour closing
buffer. Includes a one-off wipe script for the accumulated bad data.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 07:57:08 -04:00
josh 4f838d99c1 feat: add per-ride history charts with wait time and uptime tracking
Build and Deploy / Build & Push (push) Successful in 3m7s
Adds a cron-driven sampler that snapshots Queue-Times waits and Six Flags
Fast Lane data every 5 minutes into a new ride_wait_samples table, and a
clickable per-ride detail page at /park/[id]/ride/[slug] with Today / 7d /
30d Recharts views plus a 30d uptime pill. Rides are keyed by Queue-Times'
stable qt_ride_id so renames don't fragment history. Samples store
pre-bucketed local_date and local_time in the park's IANA timezone so
aggregations are pure SQL and DST-safe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:35:27 -04:00
josh bfe099322f feat: add Fast Lane wait times toggle on park pages
Build and Deploy / Build & Push (push) Successful in 1m3s
Join Fast Lane waits from the Six Flags /wait-times endpoint onto
Queue-Times rides by name. A new toggle on the live ride panel swaps
the shown wait to the Fast Lane number; regular waits and open status
still come from Queue-Times.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:51:52 -04:00
josh aa46cc1b3d fix: run startup scrape only when database is empty
Build and Deploy / Build & Push (push) Successful in 3m37s
Restores the startup scrape removed in deb8e41, gated on
getParkDayCount() < 50 so warm restarts don't hammer the API.
Cold containers (e.g. after the volume mount fix) populate
immediately instead of waiting up to 24h for tier-4 cron.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 14:45:57 -04:00
josh 8027bfc5cf rename project from SixFlagsSuperCalendar to Thoosie Calendar
Build and Deploy / Build & Push (push) Successful in 1m39s
Update package names, Docker image tags, CI/CD workflow, and
documentation to reflect the public brand name. References to
the actual Six Flags theme park chain/API are intentionally kept.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 10:06:17 -04:00
josh f610883dea fix: allow Umami tracking script in Content Security Policy
Build and Deploy / Build & Push (push) Successful in 56s
The script was already in layout.tsx but CSP blocked both loading
and sending beacons to tracking.thewrightserver.net.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 09:55:20 -04:00
josh 6f893b909f docs: fix stale Docker paths and park region counts
Build and Deploy / Build & Push (push) Successful in 1m10s
Update /app/data → /app/backend/data across all docs to match the
volume mount fix from 3c91d9a. Add missing TZ env var to the web
container snippet in OPERATIONS.md. Correct Midwest (6→7) and
West & International (6→5) park counts in ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 09:37:02 -04:00
josh 3c91d9a453 fix: mount volume at correct path so database survives updates
Build and Deploy / Build & Push (push) Successful in 1m7s
WORKDIR is /app/backend so the DB lands at /app/backend/data/parks.db,
but the volume was mounted at /app/data — a different directory. The DB
lived in the container's ephemeral layer and was wiped on every pull.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 09:13:59 -04:00
josh deb8e4169b Revert "feat: run full scrape on backend container start"
Build and Deploy / Build & Push (push) Successful in 1m18s
This reverts commit db668c0787.
2026-04-23 23:14:22 -04:00
josh 06b911917d fix: use explicit Eastern timezone for day boundary instead of system TZ
Build and Deploy / Build & Push (push) Successful in 2m50s
getTodayLocal() relied on system clock hours, which broke in the web
container (TZ defaulting to UTC) — the day flipped at 11 PM EDT (3 AM
UTC) instead of 3 AM Eastern. Now uses Intl.DateTimeFormat with an
explicit America/New_York timezone. Also replaced all toISOString()
date formatting with local-component helpers to avoid UTC conversion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 23:11:33 -04:00
josh db668c0787 feat: run full scrape on backend container start
Build and Deploy / Build & Push (push) Successful in 1m44s
Fires scrapeToday() then scrapeFullYear() as a background task on
startup so fresh deploys have data immediately instead of waiting
for the first cron tick. Staleness check makes warm restarts a no-op.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 22:28:07 -04:00
josh a53e3ffa9f docs: add comprehensive project documentation
Build and Deploy / Build & Push (push) Successful in 1m31s
Add docs/ folder with architecture, operations, API reference, and
development guides covering system design, deployment, troubleshooting,
all backend endpoints, and contributor workflows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 22:15:02 -04:00
josh 4922dce8ac docs: update README for web + backend architecture
Build and Deploy / Build & Push (push) Successful in 34s
Remove references to Playwright discovery, RCDB scraping, scraper
container, and npm run scripts. Document the new two-container setup,
tiered scheduling, backend API endpoints, and local dev workflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:54:45 -04:00
josh c5c9f750a3 chore: update Docker and CI for web + backend architecture
Build and Deploy / Build & Push (push) Successful in 2m10s
Replace scraper container with backend API container. Web image no
longer mounts a data volume or ships SQLite. Backend image runs Hono
server with node-cron scheduler, owns the database exclusively.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:49:17 -04:00
josh 3815da2d3f refactor: make frontend a pure presentation layer fetching from backend API
Server components now fetch composed data from the backend instead of
directly querying SQLite and external APIs. Removes better-sqlite3
dependency from the frontend entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:43:59 -04:00
josh ccd35c4648 chore: remove old scraper scripts, replaced by backend scheduler
Delete scripts/scrape.ts and scripts/scrape-schedule.sh — their
functionality now lives in the backend's node-cron tiered scheduler
(backend/src/services/scheduler.ts + scraper.ts).

Remove scrape and scrape:force npm scripts from package.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:33:34 -04:00
josh 70b56158d4 feat: add Hono backend API server with tiered scheduler
Standalone Node.js backend that owns the SQLite database and serves
composed data via REST endpoints. Replaces the shell-scheduled scraper
with in-process node-cron tiered scheduling.

Backend structure:
- Hono HTTP server on port 3001 with CORS and request logging
- Singleton SQLite connection with WAL mode
- In-memory TTL cache for Queue-Times and fetchToday responses
- Comparison check on fetchToday (read-before-write, only upserts on change)

API endpoints:
- GET /api/calendar/week — week schedule + live ride counts for all parks
- GET /api/calendar/:parkId/month — month calendar for one park
- GET /api/parks — park list with metadata
- GET /api/parks/:id — single park detail
- GET /api/parks/:id/rides — live rides with Queue-Times/schedule fallback
- GET /api/status — health check, scrape stats
- POST /api/scrape/trigger — manual scrape (scope: today/month/upcoming/full)

Scheduler tiers:
- Tier 1: today — hourly (Mar-Dec)
- Tier 2: current month — every 6 hours
- Tier 3: upcoming — twice daily (3 AM + 3 PM)
- Tier 4: full year — daily at 3 AM

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:38 -04:00
josh 4652a92c29 refactor: hardcode API IDs and coaster lists, remove Playwright discovery
Embed Six Flags API IDs directly in the park registry and snapshot
coaster lists from park-meta.json into a TypeScript module. This
eliminates the Playwright-based discovery script, RCDB scraper, and
runtime dependency on park-meta.json — preparing for the backend
API transition.

- Add apiId field to Park type and all 24 park entries
- Create lib/coaster-data.ts with hardcoded coaster lists
- Update page components to use park.apiId and new getCoasterSet()
- Remove scripts/discover.ts, lib/scrapers/rcdb.ts, lib/park-meta.ts
- Remove data/park-meta.json from shared volume
- Remove playwright devDependency and discover npm script
- Simplify scripts/scrape.ts (no RCDB, no discovery checks)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:25:53 -04:00
89 changed files with 8512 additions and 2590 deletions
+1
View File
@@ -5,6 +5,7 @@ node_modules
data/*.db data/*.db
data/*.db-shm data/*.db-shm
data/*.db-wal data/*.db-wal
backend/data
.env* .env*
npm-debug.log* npm-debug.log*
.DS_Store .DS_Store
+22
View File
@@ -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
+40 -4
View File
@@ -6,8 +6,44 @@ on:
- main - main
jobs: 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: build-push:
name: Build & Push name: Build & Push
needs: verify
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -25,12 +61,12 @@ jobs:
context: . context: .
target: web target: web
push: true 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 uses: docker/build-push-action@v6
with: with:
context: . context: .
target: scraper target: backend
push: true push: true
tags: ${{ vars.REGISTRY }}/${{ gitea.repository_owner }}/sixflagssupercalendar:scraper tags: ${{ vars.REGISTRY }}/${{ gitea.repository_owner }}/thoosiecalendar:backend
+7
View File
@@ -1,5 +1,7 @@
# dependencies # dependencies
/node_modules /node_modules
/backend/node_modules
/backend/dist
/.pnp /.pnp
.pnp.* .pnp.*
.yarn/* .yarn/*
@@ -33,8 +35,12 @@ yarn-error.log*
/data/*.db /data/*.db
/data/*.db-shm /data/*.db-shm
/data/*.db-wal /data/*.db-wal
/backend/data/
parks.db parks.db
# debug script artifacts
/debug/
# env files # env files
.env* .env*
!.env.example !.env.example
@@ -42,3 +48,4 @@ parks.db
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
.gstack/
+1 -1
View File
@@ -1,4 +1,4 @@
# SixFlagsSuperCalendar # Thoosie Calendar
## What This Is ## What This Is
+37 -34
View File
@@ -1,19 +1,33 @@
# Stage 1: Install all dependencies (dev included — scraper needs tsx + playwright) # ── builder: Next.js production build ────────────────────────────────────────
FROM node:22-bookworm-slim AS deps FROM node:22-bookworm-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm ci RUN npm ci
# Stage 2: Build the Next.js app
FROM deps AS builder
COPY . . COPY . .
RUN npm run build 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 ────────────────────────────────────────────────────────────────────── # ── web ──────────────────────────────────────────────────────────────────────
# Minimal Next.js runner. No playwright, no tsx, no scripts. # Minimal Next.js standalone runner. No database, no native modules.
# next build --output standalone bundles its own node_modules (incl. better-sqlite3).
FROM node:22-bookworm-slim AS web FROM node:22-bookworm-slim AS web
WORKDIR /app 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/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/public ./public
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
VOLUME ["/app/data"]
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"] CMD ["node", "server.js"]
# ── scraper ─────────────────────────────────────────────────────────────────── # ── backend ──────────────────────────────────────────────────────────────────
# Scraper-only image. No Next.js output. Runs on a nightly schedule via # Hono API server + node-cron scheduler. Owns the SQLite database exclusively.
# scripts/scrape-schedule.sh. Staleness windows are configurable via env vars: # Runs compiled JS (no tsx/tsc at runtime).
# PARK_HOURS_STALENESS_HOURS (default: 72) FROM node:22-bookworm-slim AS backend
# COASTER_STALENESS_HOURS (default: 720 = 30 days)
FROM node:22-bookworm-slim AS scraper
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PLAYWRIGHT_BROWSERS_PATH=/app/.playwright
RUN addgroup --system --gid 1001 nodejs && \ RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts COPY --from=backend-prod-deps --chown=nextjs:nodejs /app/backend/node_modules ./backend/node_modules
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib COPY --from=backend-build --chown=nextjs:nodejs /app/backend/dist ./backend/dist
COPY --from=builder --chown=nextjs:nodejs /app/tests ./tests COPY --chown=nextjs:nodejs backend/package.json ./backend/package.json
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/tsconfig.json ./tsconfig.json
# Full node_modules — includes tsx, playwright, better-sqlite3, all devDeps RUN mkdir -p /app/backend/data && chown nextjs:nodejs /app/backend/data
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules VOLUME ["/app/backend/data"]
# Install Playwright Chromium + system libraries (runs as root, then fixes ownership)
RUN npx playwright install --with-deps chromium && \
chown -R nextjs:nodejs /app/.playwright
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
VOLUME ["/app/data"]
USER nextjs USER nextjs
CMD ["sh", "/app/scripts/scrape-schedule.sh"] EXPOSE 3001
ENV PORT=3001
WORKDIR /app/backend
CMD ["node", "dist/backend/src/index.js"]
+107 -108
View File
@@ -1,6 +1,6 @@
# Thoosie Calendar # Thoosie Calendar
A week-by-week calendar showing operating hours for all Six Flags Entertainment Group theme parks — including the former Cedar Fair parks. Data is scraped from the Six Flags internal API and stored locally in SQLite. 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 ## 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) | | **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 | | **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 ## Tech Stack
- **Next.js 15** — App Router, Server Components, standalone output - **Next.js 15** — App Router, Server Components, standalone output
- **Tailwind CSS v4** — `@theme {}` CSS variables, no config file - **Tailwind CSS v4** — `@theme {}` CSS variables, no config file
- **SQLite** via `better-sqlite3` — persisted in `/app/data/parks.db` - **Hono** — lightweight TypeScript API framework for the backend
- **Playwright** — one-time headless browser run to discover each park's internal API ID - **SQLite** via `better-sqlite3` — owned exclusively by the backend
- **Six Flags CloudFront API** — `https://d18car1k0ff81h.cloudfront.net/operating-hours/park/{id}?date=YYYYMM` - **node-cron** — tiered scheduling (hourly → daily) for data freshness
- **Queue-Times.com API** — live ride open/closed status and wait times, updated every 5 minutes - **Six Flags CloudFront API** — park operating hours and ride schedules
- **Queue-Times.com API** — live ride open/closed status and wait times
## Ride Status ## 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. 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 ### 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`). ## Data Refresh
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.
The backend runs a tiered scraping schedule via node-cron:
| Tier | Schedule | Scope |
|------|----------|-------|
| 1 | Hourly (MarDec) | 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 **Prerequisites:** Node.js 22+, npm
```bash ```bash
# Install frontend dependencies
npm install npm install
npx playwright install chromium
# Install backend dependencies
cd backend && npm install && cd ..
``` ```
### Seed the database ### Start the backend
Run once to discover each park's internal API ID (opens a headless browser per park):
```bash ```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 ```bash
npm run scrape npm run dev
``` ```
Force a full re-scrape (ignores the staleness window): 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.
```bash
npm run scrape:force
```
### Debug a specific park + date ### 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 npm run debug -- --park kingsisland --date 2026-06-15
``` ```
Output is printed to the terminal and saved to `debug/{parkId}_{date}.txt`.
### Run tests ### Run tests
```bash ```bash
npm test 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 ## Deployment
The app ships as two separate Docker images that share a named volume for the SQLite database: The app ships as two Docker images:
| Image | Tag | Purpose |
|-------|-----|---------|
| Next.js web server | `:web` | Reads DB, serves content. No scraping tools. |
| Scraper + scheduler | `:scraper` | Nightly data refresh. No web server. |
Images are built and pushed automatically by CI on every push to `main`.
### First-time setup
**1. Pull the images**
```bash
docker pull gitea.thewrightserver.net/josh/sixflagssupercalendar:web
docker pull gitea.thewrightserver.net/josh/sixflagssupercalendar:scraper
```
**2. Discover park API IDs**
This one-time step opens a headless browser for each park to find its internal Six Flags API ID. Run it against the scraper image so Playwright is available:
```bash
docker run --rm -v root_park_data:/app/data \
gitea.thewrightserver.net/josh/sixflagssupercalendar:scraper \
npm run discover
```
**3. Set RCDB IDs for the coaster filter**
Open `data/park-meta.json` in the Docker volume and set `rcdb_id` for each park to the numeric ID from the RCDB URL (e.g. `https://rcdb.com/4529.htm``4529`). You can curl it directly from the repo:
```bash
curl -o /var/lib/docker/volumes/root_park_data/_data/park-meta.json \
https://gitea.thewrightserver.net/josh/SixFlagsSuperCalendar/raw/branch/main/data/park-meta.json
```
**4. Run the initial scrape**
```bash
docker run --rm -v root_park_data:/app/data \
gitea.thewrightserver.net/josh/sixflagssupercalendar:scraper \
npm run scrape
```
**5. Start services**
```bash ```bash
docker compose up -d docker compose up -d
``` ```
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 ### 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 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: | Endpoint | Description |
|----------|-------------|
| Variable | Default | 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 |
| `TZ` | `UTC` | Timezone for the nightly 3 AM run (e.g. `America/New_York`) | | `GET /api/parks/:id/rides` | Live rides or schedule fallback |
| `PARK_HOURS_STALENESS_HOURS` | `72` | Hours before park schedule data is re-fetched | | `GET /api/parks/:id/rides/:slug` | Per-ride detail + today/7d/30d wait-time history |
| `COASTER_STALENESS_HOURS` | `720` | Hours before RCDB coaster lists are re-fetched (720 = 30 days) | | `GET /api/parks` | Park list with metadata |
| `GET /api/status` | Health check, scrape timestamps, DB stats |
### Manual scrape | `POST /api/scrape/trigger?scope=...` | Manual scrape trigger |
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`.
+29
View File
@@ -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("/");
}
-59
View File
@@ -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",
},
});
}
+38
View File
@@ -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>
);
}
+9
View File
@@ -110,6 +110,15 @@
background: var(--color-surface-hover) !important; 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 ───────────────────────── */ /* ── Park month calendar — responsive row heights ───────────────────────── */
/* Mobile: fixed uniform rows so narrow columns don't cause height variance */ /* Mobile: fixed uniform rows so narrow columns don't cause height variance */
.park-calendar-grid { .park-calendar-grid {
+7 -2
View File
@@ -2,6 +2,9 @@ import type { Metadata } from "next";
import Script from "next/script"; import Script from "next/script";
import "./globals.css"; 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 = { export const metadata: Metadata = {
title: "Thoosie Calendar", title: "Thoosie Calendar",
description: "Theme park operating hours and live ride status at a glance", 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"> <html lang="en">
<body> <body>
{children} {children}
{PLAUSIBLE_SRC && PLAUSIBLE_WEBSITE_ID ? (
<Script <Script
src="https://tracking.thewrightserver.net/script.js" src={PLAUSIBLE_SRC}
data-website-id="a0d0582a-9bd0-4c0d-8e3c-3e6fcc99ec9a" data-website-id={PLAUSIBLE_WEBSITE_ID}
strategy="afterInteractive" strategy="afterInteractive"
/> />
) : null}
</body> </body>
</html> </html>
); );
+31
View File
@@ -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&apos;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>
);
}
+32 -132
View File
@@ -1,150 +1,50 @@
import type { ComponentProps } from "react";
import { cookies } from "next/headers";
import { HomePageClient } from "@/components/HomePageClient"; import { HomePageClient } from "@/components/HomePageClient";
import { PARKS } from "@/lib/parks"; import { getTodayLocal, formatDateLocal } from "@/lib/env";
import { openDb, getDateRange, getApiId } from "@/lib/db"; import { apiFetch } from "@/lib/api";
import { getTodayLocal, isWithinOperatingWindow, getOperatingStatus } from "@/lib/env";
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
import { fetchToday } from "@/lib/scrapers/sixflags";
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
import { readParkMeta, getCoasterSet } from "@/lib/park-meta";
import type { DayData } from "@/lib/db";
interface PageProps { type WeekData = ComponentProps<typeof HomePageClient>;
searchParams: Promise<{ week?: string }>;
}
function getWeekStart(param: string | undefined): string { const WEEK_COOKIE = "tcWeek";
if (param && /^\d{4}-\d{2}-\d{2}$/.test(param)) {
const d = new Date(param + "T00:00:00"); 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())) { if (!isNaN(d.getTime())) {
d.setDate(d.getDate() - d.getDay()); d.setDate(d.getDate() - d.getDay());
return d.toISOString().slice(0, 10); return formatDateLocal(d);
} }
} }
const todayIso = getTodayLocal(); const todayIso = getTodayLocal();
const d = new Date(todayIso + "T00:00:00"); const d = new Date(todayIso + "T00:00:00");
d.setDate(d.getDate() - d.getDay()); d.setDate(d.getDate() - d.getDay());
return d.toISOString().slice(0, 10); return formatDateLocal(d);
} }
function getWeekDates(sundayIso: string): string[] { export default async function HomePage() {
return Array.from({ length: 7 }, (_, i) => { const saved = (await cookies()).get(WEEK_COOKIE)?.value;
const d = new Date(sundayIso + "T00:00:00"); const weekStart = getWeekStart(saved);
d.setDate(d.getDate() + i);
return d.toISOString().slice(0, 10);
});
}
function getCurrentWeekStart(): string { const data = await apiFetch<WeekData>(
const todayIso = getTodayLocal(); `/api/calendar/week?start=${weekStart}`,
const d = new Date(todayIso + "T00:00:00"); { revalidate: 120 },
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
); );
// Always fetch both ride and coaster counts — the client decides which to display. if (!data) {
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])
);
}
return ( return (
<HomePageClient <main style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: 24, background: "var(--color-bg)" }}>
weekStart={weekStart} <div style={{ maxWidth: 480, textAlign: "center" }}>
weekDates={weekDates} <h1 style={{ fontSize: "1.2rem", fontWeight: 700, color: "var(--color-text)", marginBottom: 12 }}>
today={today} Calendar data is unavailable
isCurrentWeek={isCurrentWeek} </h1>
data={data} <p style={{ color: "var(--color-text-muted)", lineHeight: 1.6, fontSize: "0.9rem" }}>
rideCounts={rideCounts} We could not reach the backend. Refresh in a moment.
coasterCounts={coasterCounts} </p>
openParkIds={openParkIds} </div>
closingParkIds={closingParkIds} </main>
weatherDelayParkIds={weatherDelayParkIds}
hasCoasterData={hasCoasterData}
scrapedCount={scrapedCount}
/>
); );
}
return <HomePageClient {...data} />;
} }
+56
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+83 -80
View File
@@ -1,33 +1,59 @@
import Link from "next/link"; import type { Metadata } from "next";
import { BackToCalendarLink } from "@/components/BackToCalendarLink"; import { BackToCalendarLink } from "@/components/BackToCalendarLink";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { PARK_MAP } from "@/lib/parks"; import { PARK_MAP } from "@/lib/parks";
import { openDb, getParkMonthData, getApiId } from "@/lib/db";
import { scrapeRidesForDay } from "@/lib/scrapers/sixflags";
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
import { fetchToday } from "@/lib/scrapers/sixflags";
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
import { readParkMeta, getCoasterSet } from "@/lib/park-meta";
import { ParkMonthCalendar } from "@/components/ParkMonthCalendar"; import { ParkMonthCalendar } from "@/components/ParkMonthCalendar";
import { LiveRidePanel } from "@/components/LiveRidePanel"; import { LiveRidePanel } from "@/components/LiveRidePanel";
import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags"; import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags";
import type { LiveRidesResult } from "@/lib/scrapers/queuetimes"; // used as prop type below import type { LiveRidesResult } from "@/lib/scrapers/queuetimes";
import { getTodayLocal, isWithinOperatingWindow } from "@/lib/env"; import { getTodayLocal } from "@/lib/env";
import { apiFetch } from "@/lib/api";
import type { DayData } from "@/lib/types";
interface PageProps { interface PageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
searchParams: Promise<{ month?: 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)) { if (param && /^\d{4}-\d{2}$/.test(param)) {
const [y, m] = param.split("-").map(Number); const [y, m] = param.split("-").map(Number);
if (y >= 2020 && y <= 2030 && m >= 1 && m <= 12) { if (y >= 2020 && y <= 2030 && m >= 1 && m <= 12) {
return { year: y, month: m }; return param;
} }
} }
const [y, m] = getTodayLocal().split("-").map(Number); return getTodayLocal().slice(0, 7);
return { year: y, month: m };
} }
export default async function ParkPage({ params, searchParams }: PageProps) { 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); const park = PARK_MAP.get(id);
if (!park) notFound(); if (!park) notFound();
const today = getTodayLocal(); const monthStr = parseMonthParam(monthParam);
const { year, month } = parseMonthParam(monthParam); const [year, month] = monthStr.split("-").map(Number);
const db = openDb(); const [calendarData, ridesData] = await Promise.all([
const monthData = getParkMonthData(db, id, year, month); apiFetch<CalendarMonthResponse>(
const apiId = getApiId(db, id); `/api/calendar/${id}/month?month=${monthStr}`,
db.close(); { 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 if (!calendarData) {
// weather delays and hour changes surface immediately rather than showing return <DataUnavailable parkName={park.name} />;
// 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 })),
};
}
} }
// Weather delay: park is within operating hours but queue-times shows 0 open rides const { monthData, today } = calendarData;
const isWeatherDelay = const parkOpenToday = ridesData?.parkOpenToday ?? false;
withinWindow && const isWeatherDelay = ridesData?.isWeatherDelay ?? false;
liveRides !== null && const liveRides = ridesData?.liveRides ?? null;
liveRides.rides.length > 0 && const ridesResult = ridesData?.scheduleFallback ?? null;
liveRides.rides.every((r) => !r.isOpen);
// Only hit the schedule API as a fallback when Queue-Times live data is unavailable.
if (!liveRides && apiId !== null) {
ridesResult = await scrapeRidesForDay(apiId, today);
}
return ( return (
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}> <div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
@@ -133,6 +128,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
{/* ── Ride Status ─────────────────────────────────────────────────── */} {/* ── Ride Status ─────────────────────────────────────────────────── */}
<section> <section>
<SectionHeading aside={liveRides ? ( <SectionHeading aside={liveRides ? (
<span style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap", justifyContent: "flex-end" }}>
<a <a
href="https://queue-times.com" href="https://queue-times.com"
target="_blank" target="_blank"
@@ -150,6 +146,12 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
> >
via queue-times.com via queue-times.com
</a> </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}> ) : undefined}>
Rides Rides
{liveRides ? ( {liveRides ? (
@@ -167,15 +169,15 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
{liveRides ? ( {liveRides ? (
<LiveRidePanel <LiveRidePanel
parkId={id}
liveRides={liveRides} liveRides={liveRides}
parkOpenToday={!!parkOpenToday} parkOpenToday={parkOpenToday}
isWeatherDelay={isWeatherDelay} isWeatherDelay={isWeatherDelay}
/> />
) : ( ) : (
<RideList <RideList
ridesResult={ridesResult} ridesResult={ridesResult}
parkOpenToday={!!parkOpenToday} parkOpenToday={parkOpenToday}
apiIdMissing={apiId === null && !queueTimesId}
/> />
)} )}
</section> </section>
@@ -256,24 +258,10 @@ function LiveBadge() {
function RideList({ function RideList({
ridesResult, ridesResult,
parkOpenToday, parkOpenToday,
apiIdMissing,
}: { }: {
ridesResult: RidesFetchResult | null; ridesResult: RidesFetchResult | null;
parkOpenToday: boolean; 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) { if (!parkOpenToday) {
return <Callout>Park is closed today no ride schedule available.</Callout>; return <Callout>Park is closed today no ride schedule available.</Callout>;
} }
@@ -398,3 +386,18 @@ function Callout({ children }: { children: React.ReactNode }) {
</div> </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>
);
}
+38
View File
@@ -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>
);
}
+17
View File
@@ -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>
);
}
+464
View File
@@ -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 &amp; max per day</ChartHeading>
<WeeklyStatsChart data={data} hasFastLane={hasFastLane} mode="regular" />
</div>
{hasFastLane && (
<div>
<ChartHeading>Fast Lane wait avg &amp; 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&apos;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;
}
}
+1109
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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"
}
}
+72
View File
@@ -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();
+26
View File
@@ -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();
+25
View File
@@ -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;
+74
View File
@@ -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;
}
}
+385
View File
@@ -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;
}
+86
View File
@@ -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 });
});
+30
View File
@@ -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),
};
+59
View File
@@ -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();
};
}
+202
View File
@@ -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;
+19
View File
@@ -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;
+127
View File
@@ -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;
+122
View File
@@ -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;
+37
View File
@@ -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;
+21
View File
@@ -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;
+35
View File
@@ -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;
}
}
+17
View File
@@ -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);
+102
View File
@@ -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 });
}
}
+160
View File
@@ -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);
}
+192
View File
@@ -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;
}
+193
View File
@@ -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,
};
}
+21
View File
@@ -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"]
}
+4 -11
View File
@@ -1,19 +1,12 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
export function BackToCalendarLink() { export function BackToCalendarLink() {
const [href, setHref] = useState("/"); // 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
useEffect(() => { // through the URL.
const saved = localStorage.getItem("lastWeek");
if (saved) setHref(`/?week=${saved}`);
}, []);
return ( return (
<Link <Link
href={href} href="/"
className="park-name-link" className="park-name-link"
style={{ style={{
display: "flex", display: "flex",
+1 -6
View File
@@ -8,7 +8,7 @@ import { WeekNav } from "./WeekNav";
import { Legend } from "./Legend"; import { Legend } from "./Legend";
import { EmptyState } from "./EmptyState"; import { EmptyState } from "./EmptyState";
import { PARKS, groupByRegion } from "@/lib/parks"; 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 REFRESH_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
const OPEN_REFRESH_BUFFER_MS = 30_000; // 30s after opening time before hitting the API 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); return () => timeouts.forEach(clearTimeout);
}, [isCurrentWeek, today, data, router]); }, [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 toggle = () => {
const next = !coastersOnly; const next = !coastersOnly;
setCoastersOnly(next); setCoastersOnly(next);
+95 -7
View File
@@ -1,18 +1,23 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import Link from "next/link";
import type { LiveRidesResult, LiveRide } from "@/lib/scrapers/queuetimes"; import type { LiveRidesResult, LiveRide } from "@/lib/scrapers/queuetimes";
import { slugifyRideName } from "@/lib/ride-slug";
interface LiveRidePanelProps { interface LiveRidePanelProps {
parkId: string;
liveRides: LiveRidesResult; liveRides: LiveRidesResult;
parkOpenToday: boolean; parkOpenToday: boolean;
isWeatherDelay?: boolean; isWeatherDelay?: boolean;
} }
export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: LiveRidePanelProps) { export function LiveRidePanel({ parkId, liveRides, parkOpenToday, isWeatherDelay }: LiveRidePanelProps) {
const { rides } = liveRides; const { rides } = liveRides;
const hasCoasters = rides.some((r) => r.isCoaster); const hasCoasters = rides.some((r) => r.isCoaster);
const hasFastLane = rides.some((r) => r.hasFastLane);
const [coastersOnly, setCoastersOnly] = useState(false); const [coastersOnly, setCoastersOnly] = useState(false);
const [fastLaneMode, setFastLaneMode] = useState(false);
// Pre-select coaster filter if Coaster Mode is enabled on the homepage. // Pre-select coaster filter if Coaster Mode is enabled on the homepage.
useEffect(() => { useEffect(() => {
@@ -21,6 +26,21 @@ export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: Live
} }
}, [hasCoasters]); }, [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 visible = coastersOnly ? rides.filter((r) => r.isCoaster) : rides;
const openRides = visible.filter((r) => r.isOpen); const openRides = visible.filter((r) => r.isOpen);
const closedRides = visible.filter((r) => !r.isOpen); const closedRides = visible.filter((r) => !r.isOpen);
@@ -94,12 +114,44 @@ export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: Live
</div> </div>
)} )}
{/* 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 */} {/* Coaster toggle — only shown when the park has categorised coasters */}
{hasCoasters && ( {hasCoasters && (
<button <button
onClick={() => setCoastersOnly((v) => !v)} onClick={() => setCoastersOnly((v) => !v)}
style={{ style={{
marginLeft: "auto",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 5, gap: 5,
@@ -125,6 +177,8 @@ export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: Live
</button> </button>
)} )}
</div> </div>
)}
</div>
{/* ── Ride grid ────────────────────────────────────────────────────── */} {/* ── Ride grid ────────────────────────────────────────────────────── */}
<div style={{ <div style={{
@@ -132,19 +186,22 @@ export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: Live
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
gap: 6, gap: 6,
}}> }}>
{openRides.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} ride={ride} />)} {closedRides.map((ride) => <RideRow key={ride.name} parkId={parkId} ride={ride} fastLaneMode={fastLaneMode} />)}
</div> </div>
</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 showWait = ride.isOpen && ride.waitMinutes > 0;
const fastLaneActive = fastLaneMode && ride.hasFastLane;
const flWait = ride.fastLaneMinutes ?? 0;
const slug = ride.slug ?? slugifyRideName(ride.name);
return ( return (
<div style={{ <Link href={`/park/${parkId}/ride/${slug}`} className="ride-row-link" style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
@@ -154,6 +211,9 @@ function RideRow({ ride }: { ride: LiveRide }) {
border: `1px solid ${ride.isOpen ? "var(--color-open-border)" : "var(--color-border)"}`, border: `1px solid ${ride.isOpen ? "var(--color-open-border)" : "var(--color-border)"}`,
borderRadius: 8, borderRadius: 8,
opacity: ride.isOpen ? 1 : 0.6, opacity: ride.isOpen ? 1 : 0.6,
textDecoration: "none",
color: "inherit",
cursor: "pointer",
}}> }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}> <div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<span style={{ <span style={{
@@ -174,6 +234,32 @@ function RideRow({ ride }: { ride: LiveRide }) {
{ride.name} {ride.name}
</span> </span>
</div> </div>
{/* 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 && ( {showWait && (
<span style={{ <span style={{
fontSize: "0.72rem", fontSize: "0.72rem",
@@ -196,6 +282,8 @@ function RideRow({ ride }: { ride: LiveRide }) {
walk-on walk-on
</span> </span>
)} )}
</div> </>
)}
</Link>
); );
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Park } from "@/lib/scrapers/types"; import type { Park } from "@/lib/scrapers/types";
import type { DayData } from "@/lib/db"; import type { DayData } from "@/lib/types";
import type { Region } from "@/lib/parks"; import type { Region } from "@/lib/parks";
import { ParkCard } from "./ParkCard"; import { ParkCard } from "./ParkCard";
+1 -1
View File
@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import type { Park } from "@/lib/scrapers/types"; import type { Park } from "@/lib/scrapers/types";
import type { DayData } from "@/lib/db"; import type { DayData } from "@/lib/types";
import { getTimezoneAbbr } from "@/lib/env"; import { getTimezoneAbbr } from "@/lib/env";
interface ParkCardProps { interface ParkCardProps {
+1 -1
View File
@@ -1,5 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import type { DayData } from "@/lib/db"; import type { DayData } from "@/lib/types";
import { getTimezoneAbbr } from "@/lib/env"; import { getTimezoneAbbr } from "@/lib/env";
interface ParkMonthCalendarProps { interface ParkMonthCalendarProps {
+1 -1
View File
@@ -1,7 +1,7 @@
import { Fragment } from "react"; import { Fragment } from "react";
import Link from "next/link"; import Link from "next/link";
import type { Park } from "@/lib/scrapers/types"; import type { Park } from "@/lib/scrapers/types";
import type { DayData } from "@/lib/db"; import type { DayData } from "@/lib/types";
import type { Region } from "@/lib/parks"; import type { Region } from "@/lib/parks";
import { getTodayLocal, getTimezoneAbbr } from "@/lib/env"; import { getTodayLocal, getTimezoneAbbr } from "@/lib/env";
+14 -5
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { useRouter } from "next/navigation"; import { setWeek, clearWeek } from "@/app/actions/week";
interface WeekNavProps { interface WeekNavProps {
weekStart: string; // YYYY-MM-DD (Sunday) weekStart: string; // YYYY-MM-DD (Sunday)
@@ -25,16 +25,25 @@ function formatLabel(dates: string[]): string {
return `${startStr} ${endStr}`; 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 { function shiftWeek(weekStart: string, delta: number): string {
const d = new Date(weekStart + "T00:00:00"); const d = new Date(weekStart + "T00:00:00");
d.setDate(d.getDate() + delta * 7); d.setDate(d.getDate() + delta * 7);
return d.toISOString().slice(0, 10); return formatDateLocal(d);
} }
export function WeekNav({ weekStart, weekDates, isCurrentWeek }: WeekNavProps) { export function WeekNav({ weekStart, weekDates, isCurrentWeek }: WeekNavProps) {
const router = useRouter();
const nav = (delta: number) => { const nav = (delta: number) => {
router.push(`/?week=${shiftWeek(weekStart, delta)}`); void setWeek(shiftWeek(weekStart, delta));
};
const jumpToToday = () => {
void clearWeek();
}; };
useEffect(() => { useEffect(() => {
@@ -61,7 +70,7 @@ export function WeekNav({ weekStart, weekDates, isCurrentWeek }: WeekNavProps) {
{!isCurrentWeek && ( {!isCurrentWeek && (
<button <button
onClick={() => router.push("/")} onClick={jumpToToday}
aria-label="Jump to current week" aria-label="Jump to current week"
style={todayBtnStyle} style={todayBtnStyle}
onMouseOver={(e) => Object.assign((e.target as HTMLElement).style, todayBtnHover)} onMouseOver={(e) => Object.assign((e.target as HTMLElement).style, todayBtnHover)}
+41
View File
@@ -0,0 +1,41 @@
interface Props {
/** Mean uptime across the window, 01. */
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>
);
}
+210
View File
@@ -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>
);
}
+90
View File
@@ -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>
);
}
-416
View File
@@ -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",
"Sirens 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",
"Snoopys 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",
"Snoopys 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",
"Woodstocks 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
View File
@@ -1,24 +1,52 @@
services: services:
web: web:
image: gitea.thewrightserver.net/josh/sixflagssupercalendar:web image: gitea.thewrightserver.net/josh/thoosiecalendar:web
ports: ports:
- "3000:3000" - "3000:3000"
volumes:
- park_data:/app/data
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- BACKEND_URL=http://backend:3001
- TZ=America/New_York
restart: unless-stopped 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: backend:
image: gitea.thewrightserver.net/josh/sixflagssupercalendar:scraper image: gitea.thewrightserver.net/josh/thoosiecalendar:backend
ports:
- "3001:3001"
volumes: volumes:
- park_data:/app/data - park_data:/app/backend/data
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- TZ=America/New_York - TZ=America/New_York
- PARK_HOURS_STALENESS_HOURS=72 - PARK_HOURS_STALENESS_HOURS=72
- COASTER_STALENESS_HOURS=720
restart: unless-stopped 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: volumes:
park_data: park_data:
+599
View File
@@ -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
}
```
+597
View File
@@ -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 |
+295
View File
@@ -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.
+519
View File
@@ -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. |
+30
View File
@@ -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
View File
@@ -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;
}
}
+332
View File
@@ -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);
}
-288
View File
@@ -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
View File
@@ -12,26 +12,54 @@ export function parseStalenessHours(envVar: string | undefined, defaultHours: nu
return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultHours; 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 * Returns today's date as YYYY-MM-DD in Eastern time with a 3 AM switchover.
* switchover. Before 3 AM local time we still consider it "yesterday", so the * Uses Intl.DateTimeFormat so it works regardless of the system/container TZ.
* calendar doesn't flip to the next day at midnight while people are still out
* at the park.
*
* Important: `new Date().toISOString()` returns UTC, which causes the date to
* advance at 8 PM EDT (UTC-4) or 7 PM EST (UTC-5) — too early. This helper
* corrects that by using local year/month/day components and rolling back one
* day when the local hour is before 3.
*/ */
export function getTodayLocal(): string { export function getTodayLocal(): string {
const now = new Date(); const now = new Date();
if (now.getHours() < 3) { const fmt = new Intl.DateTimeFormat("en-US", {
now.setDate(now.getDate() - 1); timeZone: APP_TIMEZONE,
} year: "numeric",
const y = now.getFullYear(); month: "2-digit",
const m = String(now.getMonth() + 1).padStart(2, "0"); day: "2-digit",
const d = String(now.getDate()).padStart(2, "0"); hour: "numeric",
return `${y}-${m}-${d}`; 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}`;
} }
/** /**
+97
View File
@@ -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;
}
-67
View File
@@ -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));
}
+24
View File
@@ -11,6 +11,7 @@ export const PARKS: Park[] = [
// ── Six Flags branded parks ────────────────────────────────────────────── // ── Six Flags branded parks ──────────────────────────────────────────────
{ {
id: "greatadventure", id: "greatadventure",
apiId: 905,
name: "Six Flags Great Adventure", name: "Six Flags Great Adventure",
shortName: "Great Adventure", shortName: "Great Adventure",
chain: "sixflags", chain: "sixflags",
@@ -22,6 +23,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "magicmountain", id: "magicmountain",
apiId: 906,
name: "Six Flags Magic Mountain", name: "Six Flags Magic Mountain",
shortName: "Magic Mountain", shortName: "Magic Mountain",
chain: "sixflags", chain: "sixflags",
@@ -33,6 +35,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "greatamerica", id: "greatamerica",
apiId: 910,
name: "Six Flags Great America", name: "Six Flags Great America",
shortName: "Great America", shortName: "Great America",
chain: "sixflags", chain: "sixflags",
@@ -44,6 +47,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "overgeorgia", id: "overgeorgia",
apiId: 902,
name: "Six Flags Over Georgia", name: "Six Flags Over Georgia",
shortName: "Over Georgia", shortName: "Over Georgia",
chain: "sixflags", chain: "sixflags",
@@ -55,6 +59,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "overtexas", id: "overtexas",
apiId: 901,
name: "Six Flags Over Texas", name: "Six Flags Over Texas",
shortName: "Over Texas", shortName: "Over Texas",
chain: "sixflags", chain: "sixflags",
@@ -66,6 +71,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "stlouis", id: "stlouis",
apiId: 903,
name: "Six Flags St. Louis", name: "Six Flags St. Louis",
shortName: "St. Louis", shortName: "St. Louis",
chain: "sixflags", chain: "sixflags",
@@ -77,6 +83,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "fiestatexas", id: "fiestatexas",
apiId: 914,
name: "Six Flags Fiesta Texas", name: "Six Flags Fiesta Texas",
shortName: "Fiesta Texas", shortName: "Fiesta Texas",
chain: "sixflags", chain: "sixflags",
@@ -88,6 +95,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "newengland", id: "newengland",
apiId: 935,
name: "Six Flags New England", name: "Six Flags New England",
shortName: "New England", shortName: "New England",
chain: "sixflags", chain: "sixflags",
@@ -99,6 +107,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "discoverykingdom", id: "discoverykingdom",
apiId: 936,
name: "Six Flags Discovery Kingdom", name: "Six Flags Discovery Kingdom",
shortName: "Discovery Kingdom", shortName: "Discovery Kingdom",
chain: "sixflags", chain: "sixflags",
@@ -110,6 +119,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "mexico", id: "mexico",
apiId: 960,
name: "Six Flags Mexico", name: "Six Flags Mexico",
shortName: "Mexico", shortName: "Mexico",
chain: "sixflags", chain: "sixflags",
@@ -121,6 +131,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "greatescape", id: "greatescape",
apiId: 924,
name: "Six Flags Great Escape", name: "Six Flags Great Escape",
shortName: "Great Escape", shortName: "Great Escape",
chain: "sixflags", chain: "sixflags",
@@ -132,6 +143,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "darienlake", id: "darienlake",
apiId: 945,
name: "Six Flags Darien Lake", name: "Six Flags Darien Lake",
shortName: "Darien Lake", shortName: "Darien Lake",
chain: "sixflags", chain: "sixflags",
@@ -144,6 +156,7 @@ export const PARKS: Park[] = [
// ── Former Cedar Fair theme parks ───────────────────────────────────────── // ── Former Cedar Fair theme parks ─────────────────────────────────────────
{ {
id: "cedarpoint", id: "cedarpoint",
apiId: 1,
name: "Cedar Point", name: "Cedar Point",
shortName: "Cedar Point", shortName: "Cedar Point",
chain: "sixflags", chain: "sixflags",
@@ -155,6 +168,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "knotts", id: "knotts",
apiId: 4,
name: "Knott's Berry Farm", name: "Knott's Berry Farm",
shortName: "Knott's", shortName: "Knott's",
chain: "sixflags", chain: "sixflags",
@@ -166,6 +180,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "canadaswonderland", id: "canadaswonderland",
apiId: 40,
name: "Canada's Wonderland", name: "Canada's Wonderland",
shortName: "Canada's Wonderland", shortName: "Canada's Wonderland",
chain: "sixflags", chain: "sixflags",
@@ -177,6 +192,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "carowinds", id: "carowinds",
apiId: 30,
name: "Carowinds", name: "Carowinds",
shortName: "Carowinds", shortName: "Carowinds",
chain: "sixflags", chain: "sixflags",
@@ -188,6 +204,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "kingsdominion", id: "kingsdominion",
apiId: 25,
name: "Kings Dominion", name: "Kings Dominion",
shortName: "Kings Dominion", shortName: "Kings Dominion",
chain: "sixflags", chain: "sixflags",
@@ -199,6 +216,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "kingsisland", id: "kingsisland",
apiId: 20,
name: "Kings Island", name: "Kings Island",
shortName: "Kings Island", shortName: "Kings Island",
chain: "sixflags", chain: "sixflags",
@@ -210,6 +228,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "valleyfair", id: "valleyfair",
apiId: 14,
name: "Valleyfair", name: "Valleyfair",
shortName: "Valleyfair", shortName: "Valleyfair",
chain: "sixflags", chain: "sixflags",
@@ -221,6 +240,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "worldsoffun", id: "worldsoffun",
apiId: 6,
name: "Worlds of Fun", name: "Worlds of Fun",
shortName: "Worlds of Fun", shortName: "Worlds of Fun",
chain: "sixflags", chain: "sixflags",
@@ -232,6 +252,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "miadventure", id: "miadventure",
apiId: 12,
name: "Michigan's Adventure", name: "Michigan's Adventure",
shortName: "Michigan's Adventure", shortName: "Michigan's Adventure",
chain: "sixflags", chain: "sixflags",
@@ -243,6 +264,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "dorneypark", id: "dorneypark",
apiId: 8,
name: "Dorney Park", name: "Dorney Park",
shortName: "Dorney Park", shortName: "Dorney Park",
chain: "sixflags", chain: "sixflags",
@@ -254,6 +276,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "cagreatamerica", id: "cagreatamerica",
apiId: 35,
name: "California's Great America", name: "California's Great America",
shortName: "CA Great America", shortName: "CA Great America",
chain: "sixflags", chain: "sixflags",
@@ -265,6 +288,7 @@ export const PARKS: Park[] = [
}, },
{ {
id: "frontiercity", id: "frontiercity",
apiId: 943,
name: "Frontier City", name: "Frontier City",
shortName: "Frontier City", shortName: "Frontier City",
chain: "sixflags", chain: "sixflags",
+31
View File
@@ -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+0300U+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, "");
}
+22
View File
@@ -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}`);
}
+27 -2
View File
@@ -8,6 +8,7 @@
*/ */
import { isCoasterMatch } from "../coaster-match"; import { isCoasterMatch } from "../coaster-match";
import { scraperWarn } from "./log";
const BASE = "https://queue-times.com/parks"; const BASE = "https://queue-times.com/parks";
@@ -19,12 +20,21 @@ const HEADERS = {
}; };
export interface LiveRide { export interface LiveRide {
/** Stable Queue-Times ride ID — survives renames, used as the history key. */
qtRideId: number;
name: string; name: string;
isOpen: boolean; isOpen: boolean;
waitMinutes: number; waitMinutes: number;
lastUpdated: string; // ISO 8601 lastUpdated: string; // ISO 8601
/** True when the ride name appears in the RCDB coaster list for this park. */ /** True when the ride name appears in the RCDB coaster list for this park. */
isCoaster: boolean; 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 { export interface LiveRidesResult {
@@ -80,7 +90,14 @@ export async function fetchLiveRides(
signal: AbortSignal.timeout(10_000), signal: AbortSignal.timeout(10_000),
} as RequestInit & { next: { revalidate: number } }); } 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; const json = (await res.json()) as QTResponse;
@@ -90,6 +107,7 @@ export async function fetchLiveRides(
for (const r of land.rides ?? []) { for (const r of land.rides ?? []) {
if (!r.name) continue; if (!r.name) continue;
rides.push({ rides.push({
qtRideId: r.id,
name: r.name, name: r.name,
isOpen: r.is_open, isOpen: r.is_open,
waitMinutes: r.wait_time ?? 0, waitMinutes: r.wait_time ?? 0,
@@ -103,6 +121,7 @@ export async function fetchLiveRides(
for (const r of json.rides ?? []) { for (const r of json.rides ?? []) {
if (!r.name) continue; if (!r.name) continue;
rides.push({ rides.push({
qtRideId: r.id,
name: r.name, name: r.name,
isOpen: r.is_open, isOpen: r.is_open,
waitMinutes: r.wait_time ?? 0, waitMinutes: r.wait_time ?? 0,
@@ -120,7 +139,13 @@ export async function fetchLiveRides(
}); });
return { rides, fetchedAt: new Date().toISOString() }; 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; return null;
} }
} }
-91
View File
@@ -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(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)))
.replace(/&[a-z]+;/gi, "");
}
+188
View File
@@ -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
View File
@@ -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 * API: https://d18car1k0ff81h.cloudfront.net/operating-hours/park/{apiId}?date=YYYYMM
* Returns full month data in one request — no browser needed. * Returns full month data in one request.
*
* 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.
* *
* Rate limiting: on 429/503, exponential backoff (30s → 60s → 120s), MAX_RETRIES attempts. * 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 API_BASE = "https://d18car1k0ff81h.cloudfront.net/operating-hours/park";
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
const BASE_BACKOFF_MS = 30_000; const BASE_BACKOFF_MS = 30_000;
@@ -194,9 +193,18 @@ export async function fetchToday(apiId: number, revalidate?: number): Promise<Da
try { try {
const url = `${API_BASE}/${apiId}`; const url = `${API_BASE}/${apiId}`;
const raw = await fetchApi(url, 0, 0, revalidate); 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]); 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; return null;
} }
} }
@@ -227,11 +235,22 @@ export async function scrapeRidesForDay(
let raw: ApiResponse; let raw: ApiResponse;
try { try {
raw = await scrapeMonthRaw(apiId, year, month, revalidate); 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; 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. // The API uses "MM/DD/YYYY" internally.
const [, mm, dd] = dateIso.split("-"); const [, mm, dd] = dateIso.split("-");
@@ -263,8 +282,15 @@ export async function scrapeRidesForDay(
const nextRaw = await scrapeMonthRaw(apiId, nextYear, nextMonth, revalidate); const nextRaw = await scrapeMonthRaw(apiId, nextYear, nextMonth, revalidate);
const nextSorted = [...nextRaw.dates].sort((a, b) => a.date.localeCompare(b.date)); const nextSorted = [...nextRaw.dates].sort((a, b) => a.date.localeCompare(b.date));
dayData = nextSorted.find((d) => !d.isParkClosed) ?? nextSorted[0]; dayData = nextSorted.find((d) => !d.isParkClosed) ?? nextSorted[0];
} catch { } catch (err) {
// If the next month fetch fails, we simply have no fallback data. 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. * 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( export async function scrapeMonth(
apiId: number, 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). * Fetch park info for a given API ID. Uses the current month so there's always some data.
* Uses the current month so there's always some data.
*/ */
export async function fetchParkInfo( export async function fetchParkInfo(
apiId: number apiId: number
+1
View File
@@ -1,5 +1,6 @@
export interface Park { export interface Park {
id: string; id: string;
apiId: number;
name: string; name: string;
shortName: string; shortName: string;
chain: "sixflags" | string; chain: "sixflags" | string;
+29
View File
@@ -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}`;
}
+5
View File
@@ -0,0 +1,5 @@
export interface DayData {
isOpen: boolean;
hoursLabel: string | null;
specialType: string | null;
}
+2 -4
View File
@@ -2,17 +2,15 @@ import type { NextConfig } from "next";
const CSP = [ const CSP = [
"default-src 'self'", "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'", "style-src 'self' 'unsafe-inline'",
"img-src 'self' data:", "img-src 'self' data:",
"font-src 'self'", "font-src 'self'",
"connect-src 'self' https://queue-times.com", "connect-src 'self' https://queue-times.com https://tracking.thewrightserver.net",
"frame-ancestors 'none'", "frame-ancestors 'none'",
].join("; "); ].join("; ");
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// better-sqlite3 is a native module — must not be bundled by webpack
serverExternalPackages: ["better-sqlite3"],
output: "standalone", output: "standalone",
async headers() { async headers() {
+390 -765
View File
File diff suppressed because it is too large Load Diff
+4 -8
View File
@@ -1,5 +1,5 @@
{ {
"name": "sixflags-super-calendar", "name": "thoosie-calendar",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -7,27 +7,23 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"scrape": "tsx scripts/scrape.ts", "typecheck": "tsc --noEmit",
"scrape:force": "tsx scripts/scrape.ts --rescrape",
"discover": "tsx scripts/discover.ts",
"debug": "tsx scripts/debug.ts", "debug": "tsx scripts/debug.ts",
"test": "tsx --test tests/*.test.ts" "test": "tsx --test tests/*.test.ts"
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^12.8.0",
"next": "^15.3.0", "next": "^15.3.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"recharts": "^3.8.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22", "@types/node": "^22",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "^15.3.0", "eslint-config-next": "^15.3.0",
"playwright": "^1.59.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5" "typescript": "^5"
+2 -13
View File
@@ -9,7 +9,6 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { openDb, getApiId } from "../lib/db";
import { PARKS } from "../lib/parks"; import { PARKS } from "../lib/parks";
import { scrapeMonthRaw } from "../lib/scrapers/sixflags"; import { scrapeMonthRaw } from "../lib/scrapers/sixflags";
@@ -52,16 +51,6 @@ async function main() {
const month = parseInt(monthStr); const month = parseInt(monthStr);
const day = parseInt(dayStr); 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 lines: string[] = [];
const out = (...args: string[]) => { const out = (...args: string[]) => {
const line = args.join(" "); const line = args.join(" ");
@@ -70,13 +59,13 @@ async function main() {
}; };
out(`Park : ${park.name} (${park.id})`); out(`Park : ${park.name} (${park.id})`);
out(`API ID : ${apiId}`); out(`API ID : ${park.apiId}`);
out(`Date : ${dateStr}`); out(`Date : ${dateStr}`);
out(`Fetched : ${new Date().toISOString()}`); out(`Fetched : ${new Date().toISOString()}`);
out(""); out("");
out(`Fetching ${year}-${String(month).padStart(2, "0")} from API...`); 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 targetDate = `${String(month).padStart(2, "0")}/${String(day).padStart(2, "0")}/${year}`;
const dayData = raw.dates.find((d) => d.date === targetDate); const dayData = raw.dates.find((d) => d.date === targetDate);
-168
View File
@@ -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);
});
-45
View File
@@ -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
-164
View File
@@ -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);
});
+138
View File
@@ -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,
);
});
+140
View File
@@ -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");
});
+45
View File
@@ -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));
});
+110
View File
@@ -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:0003: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
View File
@@ -23,5 +23,5 @@
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules", "backend"]
} }