Update /app/data → /app/backend/data across all docs to match the
volume mount fix from 3c91d9a. Add missing TZ env var to the web
container snippet in OPERATIONS.md. Correct Midwest (6→7) and
West & International (6→5) park counts in ARCHITECTURE.md.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
23 KiB
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 withinPARK_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-Afterheader is present, it is respected (capped at 5 minutes). After 3 retries, aRateLimitErroris 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:
-
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 intodayCache(5-min TTL). A_checkedsentinel key prevents re-fetching parks that returnednull. -
Live ride counts -- For each park that is currently within its operating window (determined by
isWithinOperatingWindow()), fetches live ride data from Queue-Times.com viafetchLiveRides(). Counts open rides and open coasters. Results cached inridesCache(5-min TTL). -
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.
- Weather delay: Park is within its scheduled operating window, but all rides report
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 COLUMNwrapped 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.dbin Docker) - WAL journal files:
parks.db-walandparks.db-shmaccompany 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:
- Today (no date param):
GET /operating-hours/park/{apiId}-- returns a single day. - 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/timeTofrom the first item and formats to 12-hour ("10am - 6pm") - Detects passholder previews via
events[].extEventNamecontaining "passholder preview" - Handles buyouts: if
isBuyoutis 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):
- Exact normalized match -- both names are lowercased, stripped of trademark symbols, possessives, and leading "THE".
- Compact match -- spaces are removed from both names (catches "BAT GIRL" vs "Batgirl").
- 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:
- Periodic refresh:
setInterval(router.refresh, 120_000)-- re-fetches data every 2 minutes via Next.js router refresh (no full page reload). - Opening-time refresh: For each park open today, calculates milliseconds until its opening time using
msUntilLocalTime()(timezone-aware). Schedulesrouter.refresh()at opening and again 30 seconds later (to pick up ride counts after Queue-Times starts reporting). - Keyboard navigation: Left/right arrow keys navigate between weeks (via
WeekNavcomponent).
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:
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 byidgroupByRegion(parks): Groups a park array into{ region, parks }tuplesQUEUE_TIMES_IDS: Maps parkidto Queue-Times.com park ID (separate file)getCoasterSet(parkId): Returns theSet<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 |