Files
SixFlagsSuperCalendar/docs/ARCHITECTURE.md
T
josh 6447db3008
Build and Deploy / Build & Push (push) Successful in 1m7s
refactor: store selected week in a cookie, not the URL
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

23 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)
│   ├── layout.tsx               # Root layout with metadata
│   ├── loading.tsx              # Skeleton UI for streaming/suspense
│   └── 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 (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)
│
├── 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
│   └── scrapers/
│       ├── sixflags.ts          # Six Flags CloudFront API client
│       ├── queuetimes.ts        # Queue-Times.com API client
│       └── 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
│   │   ├── db/
│   │   │   ├── index.ts         # SQLite connection, schema creation, WAL mode
│   │   │   └── queries.ts       # All SQL queries (upsert, date range, staleness)
│   │   ├── 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 + schedule fallback
│   │   │   ├── status.ts        # /api/status -- health check
│   │   │   └── scrape.ts        # /api/scrape/trigger -- manual scrape
│   │   └── services/
│   │       ├── scheduler.ts     # Four-tier cron job registration
│   │       ├── scraper.ts       # Scraping orchestration (today, month, full year)
│   │       └── cache.ts         # Generic TtlCache<T> class
│   ├── data/                    # SQLite database (parks.db, auto-created)
│   ├── package.json             # Backend dependencies
│   └── tsconfig.json            # Backend TypeScript config (CommonJS, rootDir: ..)
│
├── tests/                       # 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:

    • Weather delay: Park is within its scheduled operating window, but all rides report isOpen: false. Indicated with a blue badge.
    • Closing: Current time is past the scheduled close but within a 1-hour wind-down buffer. Determined by getOperatingStatus() returning "closing".
    • Open: Within the scheduled open-to-close window.

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 default)   │  │ isMonthScraped() query        │
 │ + Next.js fetch revalidate    │ │                               │  │ MAX(scraped_at) vs staleness  │
 │                               │ │ todayCache:  live park hours  │  │ threshold (default 72h)       │
 │ week:  120s / 300s SWR        │ │ ridesCache:  ride/coaster     │  │                               │
 │ month: 300s / 600s SWR        │ │              open counts      │  │ Past months auto-skipped      │
 │ rides: 60s  / 120s SWR        │ │ liveRidesCache: full ride     │  │ "force" scope bypasses check  │
 │ parks: 3600s                  │ │              data per park     │  │                               │
 └───────────────────────────────┘ └───────────────────────────────┘  └───────────────────────────────┘

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

Database

Schema

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)
);
  • Composite primary key (park_id, date) ensures one row per park per day and supports efficient queries without secondary indexes.
  • 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.

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().
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).
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: Approximately 8,000-9,000 rows for a full year of 24 parks
  • 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 }

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

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)

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)
No secrets in frontend BACKEND_URL is an internal Docker network address, not a secret