docs: add comprehensive project documentation
Build and Deploy / Build & Push (push) Successful in 1m31s
Build and Deploy / Build & Push (push) Successful in 1m31s
Add docs/ folder with architecture, operations, API reference, and development guides covering system design, deployment, troubleshooting, all backend endpoints, and contributor workflows. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,15 @@ A week-by-week calendar showing operating hours for all Six Flags Entertainment
|
|||||||
| **Texas & South** | Over Texas, Fiesta Texas (TX), Frontier City (OK) |
|
| **Texas & South** | Over Texas, Fiesta Texas (TX), Frontier City (OK) |
|
||||||
| **West & International** | Magic Mountain (CA), Discovery Kingdom (CA), Knott's Berry Farm (CA), California's Great America (CA), Mexico |
|
| **West & International** | Magic Mountain (CA), Discovery Kingdom (CA), Knott's Berry Farm (CA), California's Great America (CA), Mexico |
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Detailed docs live in the [`docs/`](docs/) folder:
|
||||||
|
|
||||||
|
- [**Architecture**](docs/ARCHITECTURE.md) -- system design, data flow, caching layers, database schema, external APIs
|
||||||
|
- [**Operations**](docs/OPERATIONS.md) -- deployment, monitoring, troubleshooting, backup, scheduler management
|
||||||
|
- [**API Reference**](docs/API.md) -- complete backend endpoint documentation with request/response examples
|
||||||
|
- [**Development**](docs/DEVELOPMENT.md) -- local setup, project structure, adding parks, testing, code conventions
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
The app runs as two containers:
|
The app runs as two containers:
|
||||||
|
|||||||
+449
@@ -0,0 +1,449 @@
|
|||||||
|
# API Reference
|
||||||
|
|
||||||
|
> See also: [Architecture](ARCHITECTURE.md) | [Operations](OPERATIONS.md) | [Development](DEVELOPMENT.md)
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
|
||||||
|
| Context | URL |
|
||||||
|
|---------|-----|
|
||||||
|
| Local development | `http://localhost:3001` |
|
||||||
|
| Docker internal (web → backend) | `http://backend:3001` |
|
||||||
|
| External access (via host port) | `http://<host>:3001` |
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
None. All endpoints are public and unauthenticated. The scrape trigger endpoint is also unprotected -- restrict access at the network/proxy level if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### GET /api/calendar/week
|
||||||
|
|
||||||
|
Returns a 7-day calendar for all parks, starting from the given Sunday.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Param | Required | Format | Description |
|
||||||
|
|-------|----------|--------|-------------|
|
||||||
|
| `start` | Yes | `YYYY-MM-DD` | Week start date (should be a Sunday) |
|
||||||
|
|
||||||
|
**Cache:** `Cache-Control: public, max-age=120, stale-while-revalidate=300`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"weekStart": "2026-04-19",
|
||||||
|
"weekDates": ["2026-04-19", "2026-04-20", "2026-04-21", "2026-04-22", "2026-04-23", "2026-04-24", "2026-04-25"],
|
||||||
|
"today": "2026-04-23",
|
||||||
|
"isCurrentWeek": true,
|
||||||
|
"data": {
|
||||||
|
"cedarpoint": {
|
||||||
|
"2026-04-23": {
|
||||||
|
"isOpen": true,
|
||||||
|
"hoursLabel": "10am – 8pm",
|
||||||
|
"specialType": null
|
||||||
|
},
|
||||||
|
"2026-04-24": {
|
||||||
|
"isOpen": false,
|
||||||
|
"hoursLabel": null,
|
||||||
|
"specialType": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rideCounts": {
|
||||||
|
"cedarpoint": 42,
|
||||||
|
"greatadventure": 38
|
||||||
|
},
|
||||||
|
"coasterCounts": {
|
||||||
|
"cedarpoint": 12,
|
||||||
|
"greatadventure": 9
|
||||||
|
},
|
||||||
|
"openParkIds": ["cedarpoint", "greatadventure"],
|
||||||
|
"closingParkIds": [],
|
||||||
|
"weatherDelayParkIds": [],
|
||||||
|
"hasCoasterData": true,
|
||||||
|
"scrapedCount": 168
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response fields:**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `weekStart` | `string` | Echo of the `start` parameter |
|
||||||
|
| `weekDates` | `string[]` | Array of 7 date strings (Sun-Sat) |
|
||||||
|
| `today` | `string` | Current date (with 3 AM switchover) |
|
||||||
|
| `isCurrentWeek` | `boolean` | Whether this week contains today |
|
||||||
|
| `data` | `Record<parkId, Record<date, DayData>>` | Schedule data keyed by park ID and date |
|
||||||
|
| `rideCounts` | `Record<parkId, number>` | Number of open rides per park (only for currently operating parks with rides reporting) |
|
||||||
|
| `coasterCounts` | `Record<parkId, number>` | Number of open coasters per park |
|
||||||
|
| `openParkIds` | `string[]` | Parks currently within their operating window |
|
||||||
|
| `closingParkIds` | `string[]` | Parks past close but within 1-hour wind-down |
|
||||||
|
| `weatherDelayParkIds` | `string[]` | Parks within window but all rides closed |
|
||||||
|
| `hasCoasterData` | `boolean` | Always `true` (coaster data is static) |
|
||||||
|
| `scrapedCount` | `number` | Total day records returned (sanity check for empty database) |
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
|
||||||
|
| Status | Body | Condition |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| 400 | `{ "error": "Missing or invalid ?start=YYYY-MM-DD" }` | Missing or malformed `start` parameter |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/calendar/:parkId/month
|
||||||
|
|
||||||
|
Returns a month calendar for a single park.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
|
||||||
|
| Param | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `parkId` | Park identifier (e.g. `cedarpoint`, `greatadventure`) |
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Param | Required | Format | Description |
|
||||||
|
|-------|----------|--------|-------------|
|
||||||
|
| `month` | Yes | `YYYY-MM` | Month to fetch |
|
||||||
|
|
||||||
|
**Cache:** `Cache-Control: public, max-age=300, stale-while-revalidate=600`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"parkId": "cedarpoint",
|
||||||
|
"year": 2026,
|
||||||
|
"month": 6,
|
||||||
|
"monthData": {
|
||||||
|
"2026-06-01": {
|
||||||
|
"isOpen": true,
|
||||||
|
"hoursLabel": "10am – 10pm",
|
||||||
|
"specialType": null
|
||||||
|
},
|
||||||
|
"2026-06-02": {
|
||||||
|
"isOpen": true,
|
||||||
|
"hoursLabel": "10am – 8pm",
|
||||||
|
"specialType": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"today": "2026-04-23"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If viewing the current month, today's data is fetched live from the Six Flags API and merged into the response.
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
|
||||||
|
| Status | Body | Condition |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| 400 | `{ "error": "Missing or invalid ?month=YYYY-MM" }` | Missing or malformed `month` parameter |
|
||||||
|
| 400 | `{ "error": "Month must be 1-12" }` | Month value out of range |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/parks
|
||||||
|
|
||||||
|
Returns metadata for all 24 parks.
|
||||||
|
|
||||||
|
**Cache:** `Cache-Control: public, max-age=3600`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"parks": [
|
||||||
|
{
|
||||||
|
"id": "cedarpoint",
|
||||||
|
"apiId": 70,
|
||||||
|
"name": "Cedar Point",
|
||||||
|
"shortName": "Cedar Point",
|
||||||
|
"chain": "sixflags",
|
||||||
|
"slug": "cedarpoint",
|
||||||
|
"region": "Midwest",
|
||||||
|
"location": {
|
||||||
|
"lat": 41.4784,
|
||||||
|
"lng": -82.6834,
|
||||||
|
"city": "Sandusky",
|
||||||
|
"state": "OH"
|
||||||
|
},
|
||||||
|
"timezone": "America/New_York",
|
||||||
|
"website": "https://www.sixflags.com"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/parks/:id
|
||||||
|
|
||||||
|
Returns metadata for a single park.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
|
||||||
|
| Param | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `id` | Park identifier |
|
||||||
|
|
||||||
|
**Cache:** `Cache-Control: public, max-age=3600`
|
||||||
|
|
||||||
|
**Response:** A single `Park` object (same shape as one element of the `/api/parks` array).
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
|
||||||
|
| Status | Body | Condition |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| 404 | `{ "error": "Park not found" }` | Unknown park ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/parks/:id/rides
|
||||||
|
|
||||||
|
Returns live ride status or schedule fallback for a park.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
|
||||||
|
| Param | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `id` | Park identifier |
|
||||||
|
|
||||||
|
**Cache:** `Cache-Control: public, max-age=60, stale-while-revalidate=120`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"parkId": "cedarpoint",
|
||||||
|
"today": "2026-04-23",
|
||||||
|
"parkOpenToday": true,
|
||||||
|
"withinWindow": true,
|
||||||
|
"isWeatherDelay": false,
|
||||||
|
"liveRides": {
|
||||||
|
"rides": [
|
||||||
|
{
|
||||||
|
"name": "Steel Vengeance",
|
||||||
|
"isOpen": true,
|
||||||
|
"waitMinutes": 45,
|
||||||
|
"lastUpdated": "2026-04-23T18:30:00.000Z",
|
||||||
|
"isCoaster": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Millennium Force",
|
||||||
|
"isOpen": false,
|
||||||
|
"waitMinutes": 0,
|
||||||
|
"lastUpdated": "2026-04-23T18:30:00.000Z",
|
||||||
|
"isCoaster": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fetchedAt": "2026-04-23T18:35:00.000Z"
|
||||||
|
},
|
||||||
|
"scheduleFallback": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response fields:**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `parkId` | `string` | Echo of the park ID |
|
||||||
|
| `today` | `string` | Current date (with 3 AM switchover) |
|
||||||
|
| `parkOpenToday` | `boolean` | Whether the park has hours scheduled today |
|
||||||
|
| `withinWindow` | `boolean` | Whether current time is within operating hours |
|
||||||
|
| `isWeatherDelay` | `boolean` | Park is open but all rides are closed |
|
||||||
|
| `liveRides` | `LiveRidesResult \| null` | Queue-Times live data (null if park has no mapping or is outside window) |
|
||||||
|
| `scheduleFallback` | `RidesFetchResult \| null` | Six Flags schedule data (only populated when liveRides is null) |
|
||||||
|
|
||||||
|
**Data priority:**
|
||||||
|
1. If a Queue-Times mapping exists and the park is tracked, `liveRides` is populated.
|
||||||
|
2. If outside the operating window, all rides in `liveRides` are forced to `isOpen: false`.
|
||||||
|
3. If no live data is available, `scheduleFallback` is fetched from the Six Flags schedule API for the nearest open date.
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
|
||||||
|
| Status | Body | Condition |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| 404 | `{ "error": "Park not found" }` | Unknown park ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/status
|
||||||
|
|
||||||
|
Health check endpoint with database statistics.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"uptime": 86400,
|
||||||
|
"parks": 24,
|
||||||
|
"database": {
|
||||||
|
"totalDays": 8760,
|
||||||
|
"lastScrape": "2026-04-23T14:00:12.000Z"
|
||||||
|
},
|
||||||
|
"lastScrapeResult": {
|
||||||
|
"scope": "today",
|
||||||
|
"fetched": 24,
|
||||||
|
"skipped": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"updated": 3,
|
||||||
|
"startedAt": "2026-04-23T14:00:00.000Z",
|
||||||
|
"finishedAt": "2026-04-23T14:00:12.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `status` | `string` | Always `"ok"` |
|
||||||
|
| `uptime` | `number` | Process uptime in seconds |
|
||||||
|
| `parks` | `number` | Number of tracked parks (24) |
|
||||||
|
| `database.totalDays` | `number` | Total rows in `park_days` table |
|
||||||
|
| `database.lastScrape` | `string \| null` | ISO timestamp of the most recent `scraped_at` value |
|
||||||
|
| `lastScrapeResult` | `ScrapeResult \| null` | Result of the last completed scrape (null if none has run yet) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/scrape/trigger
|
||||||
|
|
||||||
|
Manually triggers a data scrape. See [Operations > Manual Scraping](OPERATIONS.md#manual-scraping) for detailed usage.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Param | Required | Default | Values | Description |
|
||||||
|
|-------|----------|---------|--------|-------------|
|
||||||
|
| `scope` | No | `today` | `today`, `month`, `upcoming`, `full`, `force` | What to scrape |
|
||||||
|
|
||||||
|
**Scope details:**
|
||||||
|
|
||||||
|
| Scope | What it scrapes | Staleness check | Inter-park delay |
|
||||||
|
|-------|----------------|-----------------|------------------|
|
||||||
|
| `today` | Today's hours only | No (uses diff-before-write) | 500ms |
|
||||||
|
| `month` | Current month | Yes (skips if fresh) | 1000ms |
|
||||||
|
| `upcoming` | Current + next month | Yes | 1000ms |
|
||||||
|
| `full` | All 12 months | Yes | 1000ms |
|
||||||
|
| `force` | All 12 months | **No** (ignores staleness) | 1000ms |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scope": "today",
|
||||||
|
"fetched": 24,
|
||||||
|
"skipped": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"updated": 3,
|
||||||
|
"startedAt": "2026-04-23T14:00:00.000Z",
|
||||||
|
"finishedAt": "2026-04-23T14:00:12.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
|
||||||
|
| Status | Body | Condition |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| 400 | `{ "error": "Invalid scope. Use: today, month, upcoming, full, force" }` | Unknown scope value |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Types
|
||||||
|
|
||||||
|
### DayData
|
||||||
|
|
||||||
|
Core schedule data for a single park on a single day.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DayData {
|
||||||
|
isOpen: boolean; // Whether the park is open
|
||||||
|
hoursLabel: string | null; // e.g. "10am – 6pm", null when closed
|
||||||
|
specialType: string | null; // "passholder_preview" or null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Park
|
||||||
|
|
||||||
|
Park metadata (static, defined in `lib/parks.ts`).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Park {
|
||||||
|
id: string; // Unique identifier (e.g. "cedarpoint")
|
||||||
|
apiId: number; // Six Flags CloudFront API park ID
|
||||||
|
name: string; // Full display name
|
||||||
|
shortName: string; // Abbreviated name
|
||||||
|
chain: string; // "sixflags"
|
||||||
|
slug: string; // URL-safe slug
|
||||||
|
region: string; // Geographic region
|
||||||
|
location: {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
timezone: string; // IANA timezone (e.g. "America/New_York")
|
||||||
|
website: string; // Park website URL
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### LiveRide
|
||||||
|
|
||||||
|
A single ride from the Queue-Times.com API.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface LiveRide {
|
||||||
|
name: string; // Ride display name
|
||||||
|
isOpen: boolean; // Currently operating
|
||||||
|
waitMinutes: number; // Current wait time (0 if closed)
|
||||||
|
lastUpdated: string; // ISO 8601 timestamp from Queue-Times
|
||||||
|
isCoaster: boolean; // Classified as a roller coaster via RCDB data
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### LiveRidesResult
|
||||||
|
|
||||||
|
Container for live ride data.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface LiveRidesResult {
|
||||||
|
rides: LiveRide[]; // All rides, sorted: open first, then alphabetical
|
||||||
|
fetchedAt: string; // ISO timestamp of when we fetched from Queue-Times
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### RidesFetchResult
|
||||||
|
|
||||||
|
Schedule-based ride data (fallback when live data is unavailable).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RidesFetchResult {
|
||||||
|
rides: RideStatus[]; // Rides with scheduled open/close times
|
||||||
|
dataDate: string; // YYYY-MM-DD the data came from (may differ from requested date)
|
||||||
|
isExact: boolean; // true if dataDate matches requested date
|
||||||
|
parkHoursLabel?: string; // Park-level hours for the data date
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RideStatus {
|
||||||
|
name: string;
|
||||||
|
isOpen: boolean; // Has scheduled operating hours
|
||||||
|
hoursLabel?: string; // e.g. "10am – 10pm"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ScrapeResult
|
||||||
|
|
||||||
|
Result of a scraping operation.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ScrapeResult {
|
||||||
|
scope: string; // What was scraped (e.g. "today", "months(2026-04)")
|
||||||
|
fetched: number; // API calls made successfully
|
||||||
|
skipped: number; // Skipped due to staleness or null response
|
||||||
|
errors: number; // Failed API calls
|
||||||
|
updated: number; // Database rows written
|
||||||
|
startedAt: string; // ISO timestamp
|
||||||
|
finishedAt: string; // ISO timestamp
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
# 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<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
|
||||||
|
|
||||||
|
```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<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).
|
||||||
|
|
||||||
|
### 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<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 |
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
# Development
|
||||||
|
|
||||||
|
> See also: [Architecture](ARCHITECTURE.md) | [Operations](OPERATIONS.md) | [API Reference](API.md)
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Node.js 22+** and **npm**
|
||||||
|
- No database tools needed (SQLite is auto-created by the backend)
|
||||||
|
- No Docker needed for local development
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone <repo-url>
|
||||||
|
cd SixFlagsSuperCalendar
|
||||||
|
|
||||||
|
# Install frontend dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Install backend dependencies
|
||||||
|
cd backend && npm install && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running Locally
|
||||||
|
|
||||||
|
The project requires two terminals -- one for the backend, one for the frontend.
|
||||||
|
|
||||||
|
### Terminal 1: Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts the Hono API server on port 3001 using `tsx` (TypeScript runtime with watch mode). On first run:
|
||||||
|
- Creates an empty SQLite database at `backend/data/parks.db`
|
||||||
|
- Registers the four-tier cron scheduler
|
||||||
|
- The schedulers will populate data automatically over time, or you can trigger a manual scrape immediately:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3001/api/scrape/trigger?scope=full
|
||||||
|
```
|
||||||
|
|
||||||
|
### Terminal 2: Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts the Next.js dev server on port 3000 with hot reload. Open [http://localhost:3000](http://localhost:3000).
|
||||||
|
|
||||||
|
**Navigation:**
|
||||||
|
- Use the `←` / `→` buttons to navigate weeks, or pass `?week=YYYY-MM-DD` in the URL
|
||||||
|
- Click any park name to open its detail page with month calendar and ride status
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure Walkthrough
|
||||||
|
|
||||||
|
### `app/` -- Next.js Pages
|
||||||
|
|
||||||
|
Two routes:
|
||||||
|
- `/` (`app/page.tsx`) -- Home page. Server component that fetches week data from the backend and passes everything to `HomePageClient`.
|
||||||
|
- `/park/[id]` (`app/park/[id]/page.tsx`) -- Park detail page. Fetches month calendar and live rides in parallel via `Promise.all`.
|
||||||
|
|
||||||
|
### `components/` -- React Components
|
||||||
|
|
||||||
|
10 components, split between server and client:
|
||||||
|
|
||||||
|
| Component | Type | Purpose |
|
||||||
|
|-----------|------|---------|
|
||||||
|
| `HomePageClient` | Client | Top-level state: coaster filter, auto-refresh, keyboard nav |
|
||||||
|
| `WeekCalendar` | Server | Desktop 7-column table with region groupings |
|
||||||
|
| `MobileCardList` | Server | Mobile card layout (below `lg` breakpoint) |
|
||||||
|
| `ParkCard` | Server | Individual park card for mobile |
|
||||||
|
| `ParkMonthCalendar` | Server | Month grid for park detail page |
|
||||||
|
| `LiveRidePanel` | Client | Live ride list with coaster toggle and wait times |
|
||||||
|
| `WeekNav` | Client | Week navigation arrows |
|
||||||
|
| `Legend` | Server | Color legend for status indicators |
|
||||||
|
| `EmptyState` | Server | Empty database message |
|
||||||
|
| `BackToCalendarLink` | Client | Back link using localStorage for last week |
|
||||||
|
|
||||||
|
### `lib/` -- Shared Code
|
||||||
|
|
||||||
|
Imported by both frontend and backend:
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `types.ts` | Core `DayData` interface |
|
||||||
|
| `env.ts` | `getTodayLocal()` (3 AM switchover), `isWithinOperatingWindow()`, `getOperatingStatus()`, `parseStalenessHours()` |
|
||||||
|
| `parks.ts` | All 24 park definitions, `PARK_MAP`, `groupByRegion()` |
|
||||||
|
| `coaster-data.ts` | Static RCDB coaster name sets per park, `getCoasterSet()` |
|
||||||
|
| `coaster-match.ts` | `normalizeForMatch()`, `isCoasterMatch()` -- fuzzy name matching |
|
||||||
|
| `queue-times-map.ts` | `QUEUE_TIMES_IDS` -- park ID to Queue-Times park ID mapping |
|
||||||
|
| `scrapers/sixflags.ts` | Six Flags CloudFront API client -- `scrapeMonth()`, `fetchToday()`, `scrapeRidesForDay()`, rate limiting |
|
||||||
|
| `scrapers/queuetimes.ts` | Queue-Times.com API client -- `fetchLiveRides()` |
|
||||||
|
| `scrapers/types.ts` | `Park`, `DayStatus`, `MonthCalendar`, `ScraperAdapter` interfaces |
|
||||||
|
|
||||||
|
### `backend/src/` -- Hono API Server
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `index.ts` | Entry point -- middleware (CORS, logger), route registration, DB init, scheduler start |
|
||||||
|
| `db/index.ts` | SQLite connection singleton, schema creation, WAL mode |
|
||||||
|
| `db/queries.ts` | All SQL queries -- `upsertDay`, `getDateRange`, `getParkMonthData`, `isMonthScraped`, etc. |
|
||||||
|
| `routes/calendar.ts` | `/api/calendar/*` -- week and month data with live today merging |
|
||||||
|
| `routes/parks.ts` | `/api/parks/*` -- park metadata |
|
||||||
|
| `routes/rides.ts` | `/api/parks/:id/rides` -- live ride status with schedule fallback |
|
||||||
|
| `routes/status.ts` | `/api/status` -- health check |
|
||||||
|
| `routes/scrape.ts` | `/api/scrape/trigger` -- manual scrape |
|
||||||
|
| `services/scheduler.ts` | Four-tier cron job registration |
|
||||||
|
| `services/scraper.ts` | Scraping orchestration -- `scrapeToday()`, `scrapeMonths()`, `scrapeFullYear()` |
|
||||||
|
| `services/cache.ts` | Generic `TtlCache<T>` class with configurable TTL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a New Park
|
||||||
|
|
||||||
|
Adding a park requires changes to three files. The park will be automatically picked up by the scheduler, the API, and the frontend.
|
||||||
|
|
||||||
|
### 1. `lib/parks.ts`
|
||||||
|
|
||||||
|
Add an entry to the `PARKS` array:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: "newpark", // URL-safe unique identifier
|
||||||
|
apiId: 123, // Six Flags CloudFront API park ID
|
||||||
|
name: "Six Flags New Park",
|
||||||
|
shortName: "New Park",
|
||||||
|
chain: "sixflags",
|
||||||
|
slug: "newpark", // Should match sixflags.com URL path
|
||||||
|
region: "Midwest", // One of: Northeast, Southeast, Midwest, Texas & South, West & International
|
||||||
|
location: {
|
||||||
|
lat: 40.0,
|
||||||
|
lng: -80.0,
|
||||||
|
city: "Anytown",
|
||||||
|
state: "OH",
|
||||||
|
},
|
||||||
|
timezone: "America/New_York", // IANA timezone
|
||||||
|
website: "https://www.sixflags.com",
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
**Finding the API ID:** Use the debug script or inspect network requests on the Six Flags website. The `apiId` is the numeric park identifier in the CloudFront API URL.
|
||||||
|
|
||||||
|
### 2. `lib/queue-times-map.ts`
|
||||||
|
|
||||||
|
Add the Queue-Times.com park ID mapping:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const QUEUE_TIMES_IDS: Record<string, number> = {
|
||||||
|
// ... existing mappings
|
||||||
|
newpark: 456, // Queue-Times park ID
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Finding the Queue-Times ID:** Browse [queue-times.com](https://queue-times.com), navigate to the park, and note the numeric ID in the URL.
|
||||||
|
|
||||||
|
### 3. `lib/coaster-data.ts`
|
||||||
|
|
||||||
|
Add the coaster name set:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function getCoasterSet(parkId: string): Set<string> | null {
|
||||||
|
// ... existing cases
|
||||||
|
case "newpark":
|
||||||
|
return new Set([
|
||||||
|
normalizeForMatch("Coaster Name One"),
|
||||||
|
normalizeForMatch("Coaster Name Two"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Finding coaster names:** Look up the park on [RCDB (Roller Coaster Database)](https://rcdb.com). List all operating roller coasters. Names should be the official RCDB names before normalization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debug Script
|
||||||
|
|
||||||
|
Inspect raw API data and parsed output for any park and date:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run debug -- --park <parkId> --date <YYYY-MM-DD>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run debug -- --park kingsisland --date 2026-06-15
|
||||||
|
```
|
||||||
|
|
||||||
|
This fetches the raw Six Flags API response for the park and date, displays the parsed result, and saves the raw JSON to the `debug/` directory for inspection. Useful for:
|
||||||
|
- Investigating API response format changes
|
||||||
|
- Debugging parsing issues for specific parks/dates
|
||||||
|
- Verifying that a park's `apiId` is correct
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses the **Node.js built-in test runner** (`node --test`). Test files live in `tests/`.
|
||||||
|
|
||||||
|
**Current test coverage:**
|
||||||
|
|
||||||
|
| File | Tests | Coverage |
|
||||||
|
|------|-------|---------|
|
||||||
|
| `tests/coaster-matching.test.ts` | 13 cases | Coaster name matching: exact, prefix, compact, conjunction rejection |
|
||||||
|
|
||||||
|
Tests verify the `isCoasterMatch()` function handles edge cases like trademark symbols, possessives, subtitles, space-split brand words, and conjunction-joined compound ride names.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Conventions
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
|
||||||
|
- **Strict mode** enabled in both `tsconfig.json` files
|
||||||
|
- Frontend uses `bundler` module resolution with `@/*` path alias
|
||||||
|
- Backend uses `CommonJS` modules with `@lib/*` alias resolving to `../lib/*`
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
- **Inline styles** via `style={{}}` props for most component styling
|
||||||
|
- **Tailwind CSS v4** for responsive utilities (`hidden lg:block`, `sm:flex`, `px-4 sm:px-6`)
|
||||||
|
- Theme defined via `@theme {}` block and CSS custom properties in `app/globals.css`
|
||||||
|
- No CSS modules, no styled-components, no component library
|
||||||
|
|
||||||
|
### Code Organization
|
||||||
|
|
||||||
|
- Shared types and utilities live in `lib/` and are imported by both frontend and backend
|
||||||
|
- No component library -- all UI is built from scratch
|
||||||
|
- Backend uses `tsx` for runtime TypeScript execution (no build step in development)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Building Docker Images Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the web image
|
||||||
|
docker build --target web -t sixflagssupercalendar:web .
|
||||||
|
|
||||||
|
# Build the backend image
|
||||||
|
docker build --target backend -t sixflagssupercalendar:backend .
|
||||||
|
|
||||||
|
# Run locally with Docker Compose
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Or run individual containers
|
||||||
|
docker run -d -p 3001:3001 -v park_data:/app/data -e TZ=America/New_York sixflagssupercalendar:backend
|
||||||
|
docker run -d -p 3000:3000 -e BACKEND_URL=http://host.docker.internal:3001 sixflagssupercalendar:web
|
||||||
|
```
|
||||||
|
|
||||||
|
When running individual containers outside of Docker Compose, use `host.docker.internal` instead of `backend` for the `BACKEND_URL`, since Docker's internal DNS won't resolve service names without Compose.
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
# Operations
|
||||||
|
|
||||||
|
> See also: [Architecture](ARCHITECTURE.md) | [API Reference](API.md) | [Development](DEVELOPMENT.md)
|
||||||
|
|
||||||
|
## Deployment Overview
|
||||||
|
|
||||||
|
The application runs as two Docker containers:
|
||||||
|
|
||||||
|
| Container | Port | Role |
|
||||||
|
|-----------|------|------|
|
||||||
|
| `web` | 3000 | Next.js frontend (stateless, no database) |
|
||||||
|
| `backend` | 3001 | Hono API server (owns SQLite database, runs cron scheduler) |
|
||||||
|
|
||||||
|
**Infrastructure requirements:**
|
||||||
|
- Docker host with Docker Compose
|
||||||
|
- Container registry (Gitea, Docker Hub, or any OCI-compatible registry)
|
||||||
|
- Outbound HTTPS access to `d18car1k0ff81h.cloudfront.net` (Six Flags API) and `queue-times.com` (live ride data)
|
||||||
|
- A reverse proxy (Traefik, nginx, Caddy, etc.) is expected to sit in front for TLS termination and domain routing, but is not included in this repository
|
||||||
|
|
||||||
|
See [Architecture](ARCHITECTURE.md) for detailed system design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Images
|
||||||
|
|
||||||
|
### Multi-Stage Build
|
||||||
|
|
||||||
|
The project uses a single `Dockerfile` with four stages producing two final images:
|
||||||
|
|
||||||
|
```
|
||||||
|
builder backend-deps
|
||||||
|
(Next.js build) (native modules)
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
web backend
|
||||||
|
(final) (final)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Stage | Base | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `builder` | `node:22-bookworm-slim` | `npm ci` + `npm run build` -- produces Next.js standalone output |
|
||||||
|
| `backend-deps` | `node:22-bookworm-slim` | Installs `python3`, `make`, `g++` for `better-sqlite3` native compilation, then `npm ci` |
|
||||||
|
| `web` (final) | `node:22-bookworm-slim` | Copies standalone output from `builder`. Non-root user. ~150MB. |
|
||||||
|
| `backend` (final) | `node:22-bookworm-slim` | Copies `node_modules` from `backend-deps` + source code. Volume for SQLite. Non-root user. ~200MB. |
|
||||||
|
|
||||||
|
### Image Tags
|
||||||
|
|
||||||
|
```
|
||||||
|
{registry}/{owner}/sixflagssupercalendar:web
|
||||||
|
{registry}/{owner}/sixflagssupercalendar:backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build web image
|
||||||
|
docker build --target web -t sixflagssupercalendar:web .
|
||||||
|
|
||||||
|
# Build backend image
|
||||||
|
docker build --target backend -t sixflagssupercalendar:backend .
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
The production `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: gitea.thewrightserver.net/josh/sixflagssupercalendar:web
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- BACKEND_URL=http://backend:3001 # Docker internal networking
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: gitea.thewrightserver.net/josh/sixflagssupercalendar:backend
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- park_data:/app/data # SQLite database persistence
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- TZ=America/New_York # Timezone for cron schedules
|
||||||
|
- PARK_HOURS_STALENESS_HOURS=72 # Hours before re-fetching park data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
park_data: # Named volume for database files
|
||||||
|
```
|
||||||
|
|
||||||
|
**Networking:** The `web` container reaches the backend via Docker's internal DNS at `http://backend:3001`. The backend port is also exposed to the host for manual API access during troubleshooting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Web Container
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `BACKEND_URL` | `http://localhost:3001` | Backend API base URL. Set to `http://backend:3001` in Docker Compose for internal networking. |
|
||||||
|
| `NODE_ENV` | -- | Set to `production` in Docker. |
|
||||||
|
| `NEXT_TELEMETRY_DISABLED` | `1` | Disables Next.js telemetry (set in Dockerfile). |
|
||||||
|
| `PORT` | `3000` | Server listen port (set in Dockerfile). |
|
||||||
|
| `HOSTNAME` | `0.0.0.0` | Bind address (set in Dockerfile to allow external access). |
|
||||||
|
|
||||||
|
### Backend Container
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `TZ` | `UTC` | Process timezone. Controls when cron jobs fire. Set to `America/New_York` in production so schedules align with US Eastern parks. |
|
||||||
|
| `PARK_HOURS_STALENESS_HOURS` | `72` | Hours before park schedule data is considered stale and re-fetched. Lower values increase API load; higher values increase data lag. |
|
||||||
|
| `NODE_ENV` | -- | Set to `production` in Docker. |
|
||||||
|
| `PORT` | `3001` | Server listen port. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI/CD Pipeline
|
||||||
|
|
||||||
|
### Gitea Actions Workflow
|
||||||
|
|
||||||
|
**File:** `.gitea/workflows/deploy.yml`
|
||||||
|
|
||||||
|
**Trigger:** Push to `main` branch.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Checkout code (`actions/checkout@v4`)
|
||||||
|
2. Log in to Gitea container registry
|
||||||
|
3. Build and push `web` image (`docker/build-push-action@v6`, target: `web`)
|
||||||
|
4. Build and push `backend` image (`docker/build-push-action@v6`, target: `backend`)
|
||||||
|
|
||||||
|
### Required Configuration
|
||||||
|
|
||||||
|
| Type | Name | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| Variable | `REGISTRY` | Container registry URL (e.g. `gitea.thewrightserver.net`) |
|
||||||
|
| Secret | `REGISTRY_TOKEN` | Authentication token for the registry |
|
||||||
|
|
||||||
|
These are configured in the Gitea repository settings under **Settings > Actions > Secrets** and **Settings > Actions > Variables**.
|
||||||
|
|
||||||
|
### Setting Up CI/CD from Scratch
|
||||||
|
|
||||||
|
1. Create a Gitea repository
|
||||||
|
2. Add `REGISTRY` as a repository variable (Settings > Actions > Variables)
|
||||||
|
3. Add `REGISTRY_TOKEN` as a repository secret (Settings > Actions > Secrets)
|
||||||
|
4. Push to `main` -- the workflow triggers automatically
|
||||||
|
5. Pull images on your Docker host: `docker compose pull && docker compose up -d`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Initial Deployment Checklist
|
||||||
|
|
||||||
|
1. **Create a `docker-compose.yml`** on your Docker host (see the Docker Compose section above, or use the one from the repository).
|
||||||
|
|
||||||
|
2. **Pull and start the containers:**
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Verify the backend started:**
|
||||||
|
```bash
|
||||||
|
docker compose logs backend
|
||||||
|
# Look for: [backend] database initialized
|
||||||
|
# [scheduler] cron jobs registered
|
||||||
|
# [backend] listening on http://localhost:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Check database status (will be empty on first run):**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3001/api/status
|
||||||
|
# { "status": "ok", "database": { "totalDays": 0, ... } }
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Trigger the initial data scrape:**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3001/api/scrape/trigger?scope=full
|
||||||
|
```
|
||||||
|
This scrapes all 12 months for all 24 parks with a 1-second delay between parks. **Expected duration: 5-10 minutes.**
|
||||||
|
|
||||||
|
6. **Verify data was scraped:**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3001/api/status
|
||||||
|
# totalDays should be ~8000-9000
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Open the web UI:** Navigate to `http://your-host:3000`.
|
||||||
|
|
||||||
|
The cron scheduler starts automatically and will keep data fresh going forward.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
- The SQLite database lives in a named Docker volume (`park_data`), so it persists across container recreations.
|
||||||
|
- Schema migrations are applied automatically on backend startup. New columns are added via `ALTER TABLE ... ADD COLUMN` wrapped in try/catch -- if the column already exists, the error is silently caught.
|
||||||
|
- No manual migration steps are needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backup and Restore
|
||||||
|
|
||||||
|
### What to Back Up
|
||||||
|
|
||||||
|
The SQLite database at `/app/data/parks.db` inside the `park_data` Docker volume. WAL journal files (`parks.db-wal` and `parks.db-shm`) must be included for a consistent backup.
|
||||||
|
|
||||||
|
### Backup Methods
|
||||||
|
|
||||||
|
**Method 1: Copy from the container**
|
||||||
|
```bash
|
||||||
|
docker compose cp backend:/app/data/parks.db ./backup/parks.db
|
||||||
|
docker compose cp backend:/app/data/parks.db-wal ./backup/parks.db-wal 2>/dev/null
|
||||||
|
docker compose cp backend:/app/data/parks.db-shm ./backup/parks.db-shm 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
**Method 2: Mount the volume to the host**
|
||||||
|
Add a bind mount in `docker-compose.yml`:
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore
|
||||||
|
|
||||||
|
1. Stop the backend: `docker compose stop backend`
|
||||||
|
2. Replace the database files in the volume
|
||||||
|
3. Restart: `docker compose start backend`
|
||||||
|
|
||||||
|
### Note on Reproducibility
|
||||||
|
|
||||||
|
All data is sourced from external APIs and is fully reproducible. If the database is lost, simply restart the backend (which auto-creates an empty database) and trigger a full scrape:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3001/api/scrape/trigger?scope=full
|
||||||
|
```
|
||||||
|
|
||||||
|
Backups are recommended for continuity (avoiding the 5-10 minute re-scrape window) but are not critical.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scheduler Operations
|
||||||
|
|
||||||
|
### Tiered Cron Schedule
|
||||||
|
|
||||||
|
The backend runs four scraping tiers via `node-cron`:
|
||||||
|
|
||||||
|
| Tier | Cron Expression | Schedule | Scope | Delay |
|
||||||
|
|------|-----------------|----------|-------|-------|
|
||||||
|
| 1 | `0 * * 3-12 *` | Hourly, March through December | Today's hours for all parks | 500ms |
|
||||||
|
| 2 | `0 */6 * * *` | Every 6 hours | Current month for all parks | 1000ms |
|
||||||
|
| 3 | `0 3,15 * * *` | 3 AM and 3 PM | Current + next month | 1000ms |
|
||||||
|
| 4 | `0 3 * * *` | Daily at 3 AM | Full year (all 12 months) | 1000ms |
|
||||||
|
|
||||||
|
**Staleness:** Tiers 2-4 skip any park-month that was scraped within `PARK_HOURS_STALENESS_HOURS` (default 72h). Tier 1 always fetches (uses diff-before-write instead).
|
||||||
|
|
||||||
|
**Off-season:** Tier 1 only runs from March through December. The month constraint `3-12` in the cron expression skips January and February when most parks are closed.
|
||||||
|
|
||||||
|
### Timezone Sensitivity
|
||||||
|
|
||||||
|
Cron expressions execute in the process timezone, controlled by the `TZ` environment variable. In production this is set to `America/New_York` so that "3 AM" aligns with US Eastern time.
|
||||||
|
|
||||||
|
The per-park timezone (e.g. `America/Los_Angeles` for Magic Mountain) is used separately for operating window detection -- it does not affect cron schedule timing.
|
||||||
|
|
||||||
|
### The 3 AM Switchover
|
||||||
|
|
||||||
|
`getTodayLocal()` in `lib/env.ts` implements a 3 AM local-time switchover: before 3 AM, the system considers it "yesterday." This prevents the calendar from flipping to the next day at midnight while park visitors are still out. The switchover uses the server's local time (influenced by `TZ`), not individual park timezones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual Scraping
|
||||||
|
|
||||||
|
Trigger a scrape at any time via the backend API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3001/api/scrape/trigger?scope=<scope>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scope Options
|
||||||
|
|
||||||
|
| Scope | Behavior | Duration |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| `today` | Fetches today's hours for all 24 parks. Diffs against database before writing. 500ms delay. | ~15s |
|
||||||
|
| `month` | Current month for all parks. Respects staleness window. 1000ms delay. | ~30s |
|
||||||
|
| `upcoming` | Current + next month. Respects staleness. | ~1min |
|
||||||
|
| `full` | All 12 months. Respects staleness. | ~5-10min |
|
||||||
|
| `force` | All 12 months. **Ignores staleness** -- forces re-fetch of everything. | ~5-10min |
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scope": "today",
|
||||||
|
"fetched": 24,
|
||||||
|
"skipped": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"updated": 3,
|
||||||
|
"startedAt": "2026-04-23T14:00:00.000Z",
|
||||||
|
"finishedAt": "2026-04-23T14:00:12.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typical Use Cases
|
||||||
|
|
||||||
|
- **After initial deployment:** `scope=full` to populate the database
|
||||||
|
- **After an extended outage:** `scope=force` to refresh all data regardless of staleness
|
||||||
|
- **Investigating a specific park:** `scope=today` to get fresh data quickly
|
||||||
|
- **Before peak season:** `scope=full` to ensure complete coverage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Health Monitoring
|
||||||
|
|
||||||
|
### Health Endpoint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3001/api/status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"uptime": 86400,
|
||||||
|
"parks": 24,
|
||||||
|
"database": {
|
||||||
|
"totalDays": 8760,
|
||||||
|
"lastScrape": "2026-04-23T14:00:12.000Z"
|
||||||
|
},
|
||||||
|
"lastScrapeResult": {
|
||||||
|
"scope": "today",
|
||||||
|
"fetched": 24,
|
||||||
|
"skipped": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"updated": 3,
|
||||||
|
"startedAt": "2026-04-23T14:00:00.000Z",
|
||||||
|
"finishedAt": "2026-04-23T14:00:12.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Metrics
|
||||||
|
|
||||||
|
| Metric | Expected Value | Concern If |
|
||||||
|
|--------|---------------|------------|
|
||||||
|
| `status` | `"ok"` | Not `"ok"` (always `"ok"` currently, but confirms the endpoint is reachable) |
|
||||||
|
| `uptime` | Increasing | Drops to 0 (container restarted) |
|
||||||
|
| `database.totalDays` | 8,000-9,000 (full year) | Much lower (scraping not running) or 0 (empty database) |
|
||||||
|
| `database.lastScrape` | Within the last hour (during operating season) | More than a few hours old (scheduler may be broken) |
|
||||||
|
| `lastScrapeResult.errors` | 0 | Consistently high (API may be blocking requests) |
|
||||||
|
|
||||||
|
### Suggested Alerting
|
||||||
|
|
||||||
|
- Alert if `database.lastScrape` is more than 12 hours old during operating season (March-December)
|
||||||
|
- Alert if `lastScrapeResult.errors` exceeds 5 on consecutive scrapes
|
||||||
|
- Alert if the health endpoint is unreachable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### No data showing in the calendar
|
||||||
|
|
||||||
|
1. **Check if the backend is running:**
|
||||||
|
```bash
|
||||||
|
docker compose logs backend --tail 50
|
||||||
|
```
|
||||||
|
Look for `[backend] listening on http://localhost:3001`.
|
||||||
|
|
||||||
|
2. **Check if the database has data:**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3001/api/status | jq .database.totalDays
|
||||||
|
```
|
||||||
|
If 0, trigger a manual scrape: `curl -X POST http://localhost:3001/api/scrape/trigger?scope=full`
|
||||||
|
|
||||||
|
3. **Check the BACKEND_URL in the web container:**
|
||||||
|
```bash
|
||||||
|
docker compose exec web env | grep BACKEND_URL
|
||||||
|
```
|
||||||
|
Should be `http://backend:3001` (not localhost, which won't resolve inside Docker).
|
||||||
|
|
||||||
|
### Ride counts not appearing on the home page
|
||||||
|
|
||||||
|
- Ride counts only appear for parks that are **currently within their operating window**, as determined by `isWithinOperatingWindow()`. Outside of park hours, no rides are shown.
|
||||||
|
- Queue-Times data is cached for 5 minutes. Recent park openings may take up to 5 minutes to appear.
|
||||||
|
- **Weather delay** (blue badge) means the park is within its hours but all rides report closed -- this is expected during weather-related closures.
|
||||||
|
- Verify the park has a Queue-Times mapping in `lib/queue-times-map.ts`.
|
||||||
|
|
||||||
|
### Stale data / not updating
|
||||||
|
|
||||||
|
1. **Check scheduler logs:**
|
||||||
|
```bash
|
||||||
|
docker compose logs backend | grep scheduler
|
||||||
|
```
|
||||||
|
You should see periodic `[scheduler] tier-X: scraping...` messages.
|
||||||
|
|
||||||
|
2. **Verify timezone:**
|
||||||
|
```bash
|
||||||
|
docker compose exec backend date
|
||||||
|
```
|
||||||
|
Should match the `TZ` environment variable (`America/New_York`).
|
||||||
|
|
||||||
|
3. **Check staleness threshold:** Data within `PARK_HOURS_STALENESS_HOURS` (default 72h) is skipped by tiers 2-4. If you recently changed park data manually, it may not be re-fetched until the staleness window expires.
|
||||||
|
|
||||||
|
4. **Force a refresh:**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3001/api/scrape/trigger?scope=force
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rate limited by Six Flags API
|
||||||
|
|
||||||
|
- Look for `[rate-limited]` messages in the backend logs:
|
||||||
|
```bash
|
||||||
|
docker compose logs backend | grep rate-limited
|
||||||
|
```
|
||||||
|
- The client uses exponential backoff: 30s, 60s, 120s, then throws a `RateLimitError` and moves to the next park.
|
||||||
|
- If rate limiting is persistent, increase `PARK_HOURS_STALENESS_HOURS` to reduce scrape frequency (e.g. 96 or 120).
|
||||||
|
- The inter-park delay is hardcoded at 1000ms (500ms for the today tier) in `backend/src/services/scraper.ts`.
|
||||||
|
|
||||||
|
### Wrong timezone / incorrect dates
|
||||||
|
|
||||||
|
- `getTodayLocal()` uses the server's local time (set by `TZ` env var) with a 3 AM cutover. Before 3 AM, the system considers it "yesterday."
|
||||||
|
- Each park has its own IANA timezone (stored in `lib/parks.ts`) used for operating window checks. The `TZ` env var only affects cron schedule timing and the "today" determination.
|
||||||
|
- If dates seem off, check both `TZ` and the server's system clock:
|
||||||
|
```bash
|
||||||
|
docker compose exec backend date
|
||||||
|
docker compose exec backend node -e "console.log(new Date().toISOString())"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database corruption
|
||||||
|
|
||||||
|
If the database becomes corrupted (unlikely with SQLite WAL mode, but possible after a hard crash):
|
||||||
|
|
||||||
|
1. Stop the backend: `docker compose stop backend`
|
||||||
|
2. Delete the database files from the volume:
|
||||||
|
```bash
|
||||||
|
docker compose run --rm backend rm -f /app/data/parks.db /app/data/parks.db-wal /app/data/parks.db-shm
|
||||||
|
```
|
||||||
|
3. Restart: `docker compose start backend` (auto-creates empty database)
|
||||||
|
4. Re-scrape: `curl -X POST http://localhost:3001/api/scrape/trigger?scope=full`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Log Reference
|
||||||
|
|
||||||
|
| Prefix | Source | Meaning |
|
||||||
|
|--------|--------|---------|
|
||||||
|
| `[backend]` | `index.ts` | Startup messages: DB initialized, server listening |
|
||||||
|
| `[scheduler]` | `scheduler.ts` | Cron job triggers with tier number |
|
||||||
|
| `[today]` | `scraper.ts` | Per-park results for the today tier (updated/skipped/error) |
|
||||||
|
| `[month]` | `scraper.ts` | Per-park-month results (open days count, rate limited, errors) |
|
||||||
|
| `[rate-limited]` | `sixflags.ts` | HTTP 429/503 with backoff timing and retry attempt count |
|
||||||
|
|
||||||
|
**Example log output:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[backend] database initialized
|
||||||
|
[scheduler] cron jobs registered
|
||||||
|
tier-1: today — hourly (Mar-Dec)
|
||||||
|
tier-2: current month — every 6h
|
||||||
|
tier-3: upcoming — 3 AM + 3 PM
|
||||||
|
tier-4: full year — 3 AM daily
|
||||||
|
[backend] listening on http://localhost:3001
|
||||||
|
[scheduler] tier-1: scraping today @ 2026-04-23T14:00:00.000Z
|
||||||
|
[today] Great Adventure: updated (open 10am - 6pm)
|
||||||
|
[today] Cedar Point: updated (open 10am - 8pm)
|
||||||
|
[today] done: 24 fetched, 3 updated, 0 skipped, 0 errors
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Tuning
|
||||||
|
|
||||||
|
| Aspect | Current Setting | Notes |
|
||||||
|
|--------|----------------|-------|
|
||||||
|
| SQLite WAL mode | Enabled | Allows concurrent reads during writes. No configuration needed. |
|
||||||
|
| In-memory cache | TtlCache (5 min TTL) | Bounded by park count -- at most ~72 entries (24 parks x 3 caches). Memory impact is negligible. |
|
||||||
|
| Staleness window | 72 hours | Controls how often park data is re-fetched from the API. Lower values = fresher data but more API calls and higher rate-limit risk. |
|
||||||
|
| Inter-park delay | 1000ms / 500ms | Hardcoded in `scraper.ts`. Provides respectful pacing against the Six Flags API. |
|
||||||
|
| ISR revalidation | 60-300s per route | Controlled in Next.js fetch calls. Lower values = fresher pages but more backend requests. |
|
||||||
|
| Next.js standalone | Enabled | Produces a minimal server bundle without unused dependencies. |
|
||||||
Reference in New Issue
Block a user