Files
josh f87462385c
Build and Deploy / Lint, typecheck, test (push) Successful in 34s
Build and Deploy / Build & Push (push) Successful in 1m6s
docs: sync README and docs/ with current codebase
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.
2026-06-02 15:31:50 -04:00

600 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
}
```