f87462385c
Surfaces features that landed after the last big docs pass: per-ride history pages, Fast Lane wait times, outage shading on the today chart, Tier-5 wait-time sampler, production-hardening pieces (rate limiter, structured logger, env validation, graceful shutdown), and the new rides + ride_wait_samples tables. Also corrects the weather-delay rule to match the "open" vs "closing" gate now in rides.ts.
600 lines
18 KiB
Markdown
600 lines
18 KiB
Markdown
# 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.
|
||
|
||
## Rate limiting
|
||
|
||
Every endpoint is gated by a fixed-window per-IP counter (`backend/src/middleware/rate-limit.ts`).
|
||
|
||
| Header / Body | Value |
|
||
|---------------|-------|
|
||
| Limit | `RATE_LIMIT_PER_MIN` env var, default `60` requests/minute |
|
||
| Window | 60 seconds, per client IP (resolved via `x-forwarded-for` → `x-real-ip` → socket address) |
|
||
| Over-limit response | `429 Too Many Requests` |
|
||
| Body | `{ "error": "Too many requests" }` |
|
||
| Response header | `Retry-After: <seconds>` — how long until the window resets |
|
||
|
||
Behind a reverse proxy, make sure `x-forwarded-for` is set or every request will appear to come from the proxy's own IP.
|
||
|
||
---
|
||
|
||
## 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",
|
||
"slug": "steel-vengeance",
|
||
"isOpen": true,
|
||
"waitMinutes": 45,
|
||
"fastLaneMinutes": 10,
|
||
"hasFastLane": true,
|
||
"lastUpdated": "2026-04-23T18:30:00.000Z",
|
||
"isCoaster": true
|
||
},
|
||
{
|
||
"name": "Millennium Force",
|
||
"slug": "millennium-force",
|
||
"isOpen": false,
|
||
"waitMinutes": 0,
|
||
"fastLaneMinutes": null,
|
||
"hasFastLane": true,
|
||
"lastUpdated": "2026-04-23T18:30:00.000Z",
|
||
"isCoaster": true
|
||
}
|
||
],
|
||
"fetchedAt": "2026-04-23T18:35:00.000Z"
|
||
},
|
||
"scheduleFallback": null
|
||
}
|
||
```
|
||
|
||
Each ride is enriched from two sources: Queue-Times.com supplies `isOpen` and the base `waitMinutes`, then Six Flags' wait-times feed is joined by name to fill in `fastLaneMinutes` and `hasFastLane`. When both sources have a regular wait for the same ride, the Six Flags value wins (Queue-Times lags around park open). `fastLaneMinutes` is `null` when the ride is closed or has no Fast Lane line. `slug` is the URL-safe identifier used by `/api/parks/:id/rides/:slug`.
|
||
|
||
**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/parks/:parkId/rides/:slug
|
||
|
||
Returns metadata + history for a single ride: today's 5-minute wait samples and daily aggregates over the last 7 and 30 calendar days. Everything ships in one round-trip — the frontend renders the Today / 7d / 30d tabs from this single payload.
|
||
|
||
**Path Parameters:**
|
||
|
||
| Param | Description |
|
||
|-------|-------------|
|
||
| `parkId` | Park identifier (e.g. `cedarpoint`) |
|
||
| `slug` | Ride slug, as returned in `LiveRide.slug` or stored in the `rides.slug` column |
|
||
|
||
**Cache:** `Cache-Control: public, max-age=60, stale-while-revalidate=120`
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"park": {
|
||
"id": "cedarpoint",
|
||
"name": "Cedar Point",
|
||
"shortName": "Cedar Point",
|
||
"timezone": "America/New_York"
|
||
},
|
||
"ride": {
|
||
"qtRideId": 257,
|
||
"slug": "steel-vengeance",
|
||
"name": "Steel Vengeance",
|
||
"isCoaster": true,
|
||
"hasFastLane": true,
|
||
"firstSeen": "2026-03-15T14:05:00.000Z",
|
||
"lastSeen": "2026-04-23T18:35:00.000Z"
|
||
},
|
||
"live": {
|
||
"isOpen": true,
|
||
"waitMinutes": 45,
|
||
"hasFastLane": true,
|
||
"fastLaneMinutes": 10,
|
||
"lastUpdated": "2026-04-23T18:30:00.000Z"
|
||
},
|
||
"todayLocal": "2026-04-23",
|
||
"today": [
|
||
{
|
||
"recordedAt": "2026-04-23T14:05:12.000Z",
|
||
"localTime": "10:05",
|
||
"isOpen": true,
|
||
"waitMinutes": 15,
|
||
"fastLaneMinutes": 5
|
||
}
|
||
],
|
||
"last7d": [
|
||
{
|
||
"localDate": "2026-04-17",
|
||
"avgWait": 38.4,
|
||
"maxWait": 90,
|
||
"avgFastLane": 9.1,
|
||
"maxFastLane": 25,
|
||
"uptimePct": 0.94,
|
||
"sampleCount": 132
|
||
}
|
||
],
|
||
"last30d": [],
|
||
"coverage": {
|
||
"daysWith7d": 6,
|
||
"daysWith30d": 23,
|
||
"todaySampleCount": 1
|
||
}
|
||
}
|
||
```
|
||
|
||
**Response fields:**
|
||
|
||
| Field | Type | Description |
|
||
|-------|------|-------------|
|
||
| `park` | `{ id, name, shortName, timezone }` | Park identity (timezone is the IANA tz used for sample bucketing) |
|
||
| `ride` | `RideRecord` | Canonical row from the `rides` table |
|
||
| `live` | `LiveRideSummary \| null` | Best-effort current state pulled from the shared in-memory cache. No upstream fetch — populated by the rides route and Tier-5 sampler. `null` if no recent observation exists. |
|
||
| `todayLocal` | `string` | Today's date in the park's timezone |
|
||
| `today` | `DailySample[]` | Per-sample series for `todayLocal`, ordered by `recordedAt` |
|
||
| `last7d` | `DailyAggregate[]` | One row per `local_date` over the last 7 calendar days (inclusive of today) |
|
||
| `last30d` | `DailyAggregate[]` | Same aggregates over 30 days |
|
||
| `coverage.daysWith7d` | `number` | Distinct dates with samples in the 7-day window — use to gate the 7d tab |
|
||
| `coverage.daysWith30d` | `number` | Distinct dates with samples in the 30-day window |
|
||
| `coverage.todaySampleCount` | `number` | Number of samples already collected today |
|
||
|
||
`DailySample` and `DailyAggregate` shapes are listed under [Data Types](#data-types).
|
||
|
||
**Errors:**
|
||
|
||
| Status | Body | Condition |
|
||
|--------|------|-----------|
|
||
| 404 | `{ "error": "Park not found" }` | Unknown park ID |
|
||
| 404 | `{ "error": "Ride not found or no history yet" }` | Slug doesn't match any row in `rides` for this park (Tier-5 hasn't seen the ride yet, or the slug is wrong) |
|
||
|
||
---
|
||
|
||
### 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
|
||
slug: string; // URL-safe slug for /api/parks/:id/rides/:slug
|
||
isOpen: boolean; // Currently operating
|
||
waitMinutes: number; // Current regular wait (0 if closed)
|
||
fastLaneMinutes?: number | null; // Fast Lane wait (null when closed or no Fast Lane line)
|
||
hasFastLane?: boolean; // Ride has a Fast Lane offering per Six Flags
|
||
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"
|
||
}
|
||
```
|
||
|
||
### DailySample
|
||
|
||
A single wait-time observation recorded by the Tier-5 sampler.
|
||
|
||
```typescript
|
||
interface DailySample {
|
||
recordedAt: string; // ISO 8601 UTC timestamp
|
||
localTime: string; // HH:MM in the park's timezone
|
||
isOpen: boolean; // Ride open at this sample
|
||
waitMinutes: number | null; // Regular wait, null when unobserved
|
||
fastLaneMinutes: number | null; // Fast Lane wait, null when no Fast Lane or unobserved
|
||
}
|
||
```
|
||
|
||
### DailyAggregate
|
||
|
||
Per-day statistics computed in SQL from `ride_wait_samples`. Only open samples contribute to wait averages.
|
||
|
||
```typescript
|
||
interface DailyAggregate {
|
||
localDate: string; // YYYY-MM-DD in the park's timezone
|
||
avgWait: number | null; // Mean wait_minutes across open samples
|
||
maxWait: number | null; // Highest wait_minutes across open samples
|
||
avgFastLane: number | null; // Mean fast_lane_minutes across open samples
|
||
maxFastLane: number | null; // Highest fast_lane_minutes across open samples
|
||
uptimePct: number; // Fraction of samples with is_open=1 (0..1)
|
||
sampleCount: number; // Total samples for the day
|
||
}
|
||
```
|
||
|
||
### 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
|
||
}
|
||
```
|