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

18 KiB
Raw Permalink Blame History

API Reference

See also: Architecture | Operations | Development

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-forx-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:

{
    "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:

{
    "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:

{
    "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:

{
    "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:

{
    "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.

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:

{
    "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 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:

{
    "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.

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).

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.

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.

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).

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.

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.

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.

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
}