# 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) │ ├── 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 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 (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 ```sql 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/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:** ```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 }` ### 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` 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). ### localStorage | Key | Purpose | |-----|---------| | `lastWeek` | Remembers the last viewed week start date, used by `BackToCalendarLink` to return to the correct week | | `coasterMode` | Persists the "Coasters only" toggle state across sessions | ### 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 (6 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 (6 parks): Magic Mountain, Discovery Kingdom, Knott's Berry Farm, California's Great America, Mexico **Lookup utilities:** - `PARK_MAP`: `Map` 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` 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 |