f87462385c
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.
598 lines
32 KiB
Markdown
598 lines
32 KiB
Markdown
# 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 |
|