Files
SixFlagsSuperCalendar/docs/ARCHITECTURE.md
T
josh f87462385c
Build and Deploy / Lint, typecheck, test (push) Successful in 34s
Build and Deploy / Build & Push (push) Successful in 1m6s
docs: sync README and docs/ with current codebase
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

32 KiB
Raw Blame History

Architecture

See also: Operations | API Reference | Development

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 and 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 ({ 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).

-- 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:

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:

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:

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