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.
This commit is contained in:
+155
-5
@@ -14,6 +14,20 @@
|
||||
|
||||
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
|
||||
@@ -226,15 +240,21 @@ Returns live ride status or schedule fallback for a park.
|
||||
"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
|
||||
}
|
||||
@@ -245,6 +265,8 @@ Returns live ride status or schedule fallback for a park.
|
||||
}
|
||||
```
|
||||
|
||||
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 |
|
||||
@@ -270,6 +292,101 @@ Returns live ride status or schedule fallback for a park.
|
||||
|
||||
---
|
||||
|
||||
### 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.
|
||||
@@ -394,11 +511,14 @@ 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
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -432,6 +552,36 @@ interface RideStatus {
|
||||
}
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
Reference in New Issue
Block a user