docs: sync README and docs/ with current codebase
Build and Deploy / Lint, typecheck, test (push) Successful in 34s
Build and Deploy / Build & Push (push) Successful in 1m6s

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:
2026-06-02 15:31:50 -04:00
parent 2e9cec0b56
commit f87462385c
5 changed files with 397 additions and 72 deletions
+155 -5
View File
@@ -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.