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:
+121
-23
@@ -69,8 +69,12 @@ The web container reaches the backend via Docker internal networking (`http://ba
|
||||
├── app/ # Next.js App Router
|
||||
│ ├── page.tsx # Home page (week calendar, server component)
|
||||
│ ├── park/[id]/page.tsx # Park detail page (month calendar + rides)
|
||||
│ ├── park/[id]/error.tsx # Per-route error boundary
|
||||
│ ├── park/[id]/ride/[slug]/page.tsx # Ride detail + history page
|
||||
│ ├── layout.tsx # Root layout with metadata
|
||||
│ ├── loading.tsx # Skeleton UI for streaming/suspense
|
||||
│ ├── error.tsx # Top-level error boundary (client)
|
||||
│ ├── not-found.tsx # 404 page
|
||||
│ └── globals.css # Tailwind v4 theme + custom CSS variables
|
||||
│
|
||||
├── components/ # React components
|
||||
@@ -79,11 +83,15 @@ The web container reaches the backend via Docker internal networking (`http://ba
|
||||
│ ├── MobileCardList.tsx # Mobile card layout
|
||||
│ ├── ParkCard.tsx # Individual park card
|
||||
│ ├── ParkMonthCalendar.tsx # Month grid for park detail page
|
||||
│ ├── LiveRidePanel.tsx # Live ride status with wait times (client)
|
||||
│ ├── LiveRidePanel.tsx # Live ride status with wait times + Fast Lane toggle (client)
|
||||
│ ├── WeekNav.tsx # Week navigation arrows (client)
|
||||
│ ├── Legend.tsx # Status color legend
|
||||
│ ├── EmptyState.tsx # Shown when no data is scraped
|
||||
│ └── BackToCalendarLink.tsx # Navigation helper (client)
|
||||
│ ├── BackToCalendarLink.tsx # Navigation helper (client)
|
||||
│ └── charts/ # Recharts-based charts (client components)
|
||||
│ ├── WaitTimeTodayChart.tsx # Today's 5-min samples with outage shading
|
||||
│ ├── WeeklyStatsChart.tsx # 7d / 30d daily aggregates
|
||||
│ └── UptimePill.tsx # Compact uptime % indicator
|
||||
│
|
||||
├── lib/ # Shared code (imported by both frontend and backend)
|
||||
│ ├── types.ts # Core DayData interface
|
||||
@@ -92,32 +100,46 @@ The web container reaches the backend via Docker internal networking (`http://ba
|
||||
│ ├── coaster-data.ts # Static RCDB coaster name sets per park
|
||||
│ ├── coaster-match.ts # Fuzzy name matching (normalize, prefix, compact)
|
||||
│ ├── queue-times-map.ts # Park ID -> Queue-Times.com park ID mapping
|
||||
│ ├── api.ts # apiFetch() helper (revalidate vs. no-store option)
|
||||
│ ├── outage.ts # computeOutages() — contiguous-closed-run detection
|
||||
│ ├── ride-slug.ts # slugifyRideName() — URL slug for ride pages
|
||||
│ ├── timezone.ts # formatLocalDate / formatLocalTime in a park's tz
|
||||
│ └── scrapers/
|
||||
│ ├── sixflags.ts # Six Flags CloudFront API client
|
||||
│ ├── queuetimes.ts # Queue-Times.com API client
|
||||
│ └── types.ts # Park, DayStatus, MonthCalendar, ScraperAdapter interfaces
|
||||
│ ├── sixflags.ts # Six Flags CloudFront operating-hours client
|
||||
│ ├── sixflags-waittimes.ts # Six Flags Fast Lane wait-times client
|
||||
│ ├── queuetimes.ts # Queue-Times.com API client
|
||||
│ ├── log.ts # Shared scraper logger
|
||||
│ └── types.ts # Park, DayStatus, MonthCalendar, ScraperAdapter interfaces
|
||||
│
|
||||
├── backend/ # Hono API server (separate package.json)
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # Entry point: middleware, routes, DB init, scheduler start
|
||||
│ │ ├── index.ts # Entry point: middleware, routes, DB init, scheduler start, graceful shutdown
|
||||
│ │ ├── config.ts # Env-validated config object (fails fast on bad input)
|
||||
│ │ ├── log.ts # Structured logger (`[ISO] [LEVEL] [tag] msg key=value`)
|
||||
│ │ ├── db/
|
||||
│ │ │ ├── index.ts # SQLite connection, schema creation, WAL mode
|
||||
│ │ │ └── queries.ts # All SQL queries (upsert, date range, staleness)
|
||||
│ │ │ ├── index.ts # SQLite connection, schema for park_days / rides / ride_wait_samples, WAL mode
|
||||
│ │ │ └── queries.ts # All SQL queries (upsert, date range, staleness, samples, aggregates)
|
||||
│ │ ├── middleware/
|
||||
│ │ │ └── rate-limit.ts # Fixed-window per-IP limiter (honours x-forwarded-for)
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── calendar.ts # /api/calendar/* -- week and month data with live merging
|
||||
│ │ │ ├── parks.ts # /api/parks/* -- park metadata
|
||||
│ │ │ ├── rides.ts # /api/parks/:id/rides -- live rides + schedule fallback
|
||||
│ │ │ ├── rides.ts # /api/parks/:id/rides -- live rides + Fast Lane + schedule fallback
|
||||
│ │ │ ├── ride-history.ts # /api/parks/:id/rides/:slug -- ride detail + today/7d/30d history
|
||||
│ │ │ ├── status.ts # /api/status -- health check
|
||||
│ │ │ └── scrape.ts # /api/scrape/trigger -- manual scrape
|
||||
│ │ └── services/
|
||||
│ │ ├── scheduler.ts # Four-tier cron job registration
|
||||
│ │ ├── scheduler.ts # Five-tier cron jobs with per-tier concurrency latches
|
||||
│ │ ├── scraper.ts # Scraping orchestration (today, month, full year)
|
||||
│ │ ├── wait-sampler.ts # Tier-5: 5-min wait-time sampling into ride_wait_samples
|
||||
│ │ ├── live-cache.ts # Shared TtlCaches (liveRidesCache, fastLaneCache, todayCache)
|
||||
│ │ └── cache.ts # Generic TtlCache<T> class
|
||||
│ ├── tests/ # Backend Node test runner suite
|
||||
│ ├── data/ # SQLite database (parks.db, auto-created)
|
||||
│ ├── package.json # Backend dependencies
|
||||
│ └── tsconfig.json # Backend TypeScript config (CommonJS, rootDir: ..)
|
||||
│
|
||||
├── tests/ # Unit tests (Node built-in test runner)
|
||||
├── tests/ # Frontend unit tests (Node built-in test runner)
|
||||
├── scripts/ # Debug utility
|
||||
├── public/ # Static assets
|
||||
├── Dockerfile # Multi-stage build (web + backend targets)
|
||||
@@ -212,9 +234,9 @@ When the requested week includes today, the `/api/calendar/week` route enhances
|
||||
2. **Live ride counts** -- For each park that is currently within its operating window (determined by `isWithinOperatingWindow()`), fetches live ride data from Queue-Times.com via `fetchLiveRides()`. Counts open rides and open coasters. Results cached in `ridesCache` (5-min TTL).
|
||||
|
||||
3. **Status detection:**
|
||||
- **Weather delay**: Park is within its scheduled operating window, but _all_ rides report `isOpen: false`. Indicated with a blue badge.
|
||||
- **Closing**: Current time is past the scheduled close but within a 1-hour wind-down buffer. Determined by `getOperatingStatus()` returning `"closing"`.
|
||||
- **Open**: Within the scheduled open-to-close window.
|
||||
- **Open**: Within the scheduled open-to-close window. `getOperatingStatus()` returns `"open"`.
|
||||
- **Closing**: Current time is past the scheduled close but within a 1-hour wind-down buffer. `getOperatingStatus()` returns `"closing"`.
|
||||
- **Weather delay**: `getOperatingStatus()` is `"open"` _and_ every reported ride has `isOpen: false`. Indicated with a blue badge. The badge is intentionally suppressed during the `"closing"` wind-down — all-rides-closed near close is normal end-of-day behavior, not weather. Logic lives at [backend/src/routes/rides.ts:96-100](../backend/src/routes/rides.ts) and [backend/src/routes/calendar.ts](../backend/src/routes/calendar.ts).
|
||||
|
||||
The 3 AM switchover in `getTodayLocal()` prevents the calendar from flipping to the next day at midnight -- before 3 AM local time, the system still considers it "yesterday", since park visitors may still be out.
|
||||
|
||||
@@ -228,16 +250,19 @@ The system uses three layers of caching, each serving a different purpose:
|
||||
Layer 1: Next.js ISR Layer 2: Backend In-Memory Layer 3: Database Staleness
|
||||
(serves stale while revalidating) (prevents redundant API calls) (controls scrape frequency)
|
||||
┌───────────────────────────────┐ ┌───────────────────────────────┐ ┌───────────────────────────────┐
|
||||
│ Cache-Control response headers│ │ TtlCache<T> (5 min default) │ │ isMonthScraped() query │
|
||||
│ Cache-Control response headers│ │ TtlCache<T> (5 min TTL) │ │ isMonthScraped() query │
|
||||
│ + Next.js fetch revalidate │ │ │ │ MAX(scraped_at) vs staleness │
|
||||
│ │ │ todayCache: live park hours │ │ threshold (default 72h) │
|
||||
│ week: 120s / 300s SWR │ │ ridesCache: ride/coaster │ │ │
|
||||
│ month: 300s / 600s SWR │ │ open counts │ │ Past months auto-skipped │
|
||||
│ rides: 60s / 120s SWR │ │ liveRidesCache: full ride │ │ "force" scope bypasses check │
|
||||
│ parks: 3600s │ │ data per park │ │ │
|
||||
│ │ │ todayCache: routes/calendar│ │ threshold (default 72h) │
|
||||
│ week: 120s / 300s SWR │ │ (live park hours per park) │ │ │
|
||||
│ month: 300s / 600s SWR │ │ liveRidesCache, fastLaneCache:│ │ Past months auto-skipped │
|
||||
│ rides: 60s / 120s SWR │ │ services/live-cache.ts — │ │ "force" scope bypasses check │
|
||||
│ parks: 3600s │ │ shared by rides routes + │ │ │
|
||||
│ │ │ the Tier-5 sampler │ │ │
|
||||
└───────────────────────────────┘ └───────────────────────────────┘ └───────────────────────────────┘
|
||||
```
|
||||
|
||||
**Live-page Data Cache bypass.** The park detail page (`app/park/[id]/page.tsx`) and ride detail page (`app/park/[id]/ride/[slug]/page.tsx`) fetch their live ride data with `cache: "no-store"` via [`apiFetch`](../lib/api.ts) (`{ noStore: true }`). Earlier revisions used Next.js ISR for these too, but the Data Cache served stale ride state after idle periods — navigation back to a park would show ride statuses from hours ago. Backend HTTP cache headers still allow the upstream Hono server to return cached responses for 60s, so this is a "skip the Next.js Data Cache" change, not a "skip all caching" change. The home calendar page keeps its ISR revalidation since its data is intrinsically slower-moving.
|
||||
|
||||
**Per-route HTTP cache headers:**
|
||||
|
||||
| Endpoint | `max-age` | `stale-while-revalidate` |
|
||||
@@ -247,6 +272,7 @@ The system uses three layers of caching, each serving a different purpose:
|
||||
| `/api/parks` | 3600s | -- |
|
||||
| `/api/parks/:id` | 3600s | -- |
|
||||
| `/api/parks/:id/rides` | 60s | 120s |
|
||||
| `/api/parks/:id/rides/:slug` | 60s | 120s |
|
||||
|
||||
---
|
||||
|
||||
@@ -254,7 +280,10 @@ The system uses three layers of caching, each serving a different purpose:
|
||||
|
||||
### Schema
|
||||
|
||||
The database has three tables: `park_days` (calendar hours), `rides` (per-ride metadata), and `ride_wait_samples` (time-series wait data).
|
||||
|
||||
```sql
|
||||
-- Park operating hours, keyed by park and date.
|
||||
CREATE TABLE IF NOT EXISTS park_days (
|
||||
park_id TEXT NOT NULL, -- matches Park.id from lib/parks.ts (e.g. "cedarpoint")
|
||||
date TEXT NOT NULL, -- ISO date: YYYY-MM-DD
|
||||
@@ -264,11 +293,42 @@ CREATE TABLE IF NOT EXISTS park_days (
|
||||
scraped_at TEXT NOT NULL, -- ISO timestamp of when this row was written
|
||||
PRIMARY KEY (park_id, date)
|
||||
);
|
||||
|
||||
-- Per-ride canonical record. PK is (park_id, qt_ride_id) so ride renames
|
||||
-- don't fragment history — the slug just provides pretty URLs.
|
||||
CREATE TABLE IF NOT EXISTS rides (
|
||||
park_id TEXT NOT NULL,
|
||||
qt_ride_id INTEGER NOT NULL, -- Queue-Times ride ID (stable upstream)
|
||||
slug TEXT NOT NULL, -- URL slug (rebuilt if name changes)
|
||||
name TEXT NOT NULL, -- Display name as last seen
|
||||
is_coaster INTEGER NOT NULL DEFAULT 0,
|
||||
has_fast_lane INTEGER NOT NULL DEFAULT 0,
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
PRIMARY KEY (park_id, qt_ride_id)
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_rides_slug ON rides (park_id, slug);
|
||||
|
||||
-- Time-series wait samples written by Tier-5 every 5 minutes for currently
|
||||
-- open parks. `recorded_at` is UTC; `local_date` / `local_time` are bucketed
|
||||
-- in the park's IANA timezone at insert time so reads are pure SQL and DST-safe.
|
||||
CREATE TABLE IF NOT EXISTS ride_wait_samples (
|
||||
park_id TEXT NOT NULL,
|
||||
qt_ride_id INTEGER NOT NULL,
|
||||
recorded_at TEXT NOT NULL, -- ISO UTC
|
||||
local_date TEXT NOT NULL, -- YYYY-MM-DD in park tz
|
||||
local_time TEXT NOT NULL, -- HH:MM in park tz
|
||||
is_open INTEGER NOT NULL,
|
||||
wait_minutes INTEGER, -- Regular line wait
|
||||
fast_lane_minutes INTEGER, -- Six Flags Fast Lane wait, if known
|
||||
PRIMARY KEY (park_id, qt_ride_id, recorded_at)
|
||||
);
|
||||
```
|
||||
|
||||
- **Composite primary key** `(park_id, date)` ensures one row per park per day and supports efficient queries without secondary indexes.
|
||||
- **Composite primary keys** ensure one row per logical unit (per park-day, per ride, per sample) and support efficient queries without secondary indexes. `idx_rides_slug` lets the ride detail route resolve a `slug` to a `qt_ride_id` in one lookup.
|
||||
- **WAL mode** (`PRAGMA journal_mode = WAL`) enables concurrent reads while the scraper writes.
|
||||
- **Migration strategy**: New columns are added via `ALTER TABLE ... ADD COLUMN` wrapped in try/catch. If the column already exists, the error is silently caught. This allows the schema to evolve without a migration framework.
|
||||
- **Sample volume**: Tier-5 writes one row per open ride every 5 minutes during park hours. A park with 50 rides operating for 10 hours generates ~6,000 sample rows/day. `INSERT OR IGNORE` on the PK makes the sampler idempotent across retries.
|
||||
|
||||
### Key Queries
|
||||
|
||||
@@ -278,14 +338,24 @@ CREATE TABLE IF NOT EXISTS park_days (
|
||||
| `getDateRange(start, end)` | Returns all parks' data for a date range. Powers the week calendar. |
|
||||
| `getParkMonthData(parkId, year, month)` | Returns one park's data for a month. Uses `LIKE` prefix matching on date. |
|
||||
| `getDayData(parkId, date)` | Returns a single day for comparison during `scrapeToday()`. |
|
||||
| `getParkDayCount()` | Total rows in `park_days`. Drives the startup-scrape-when-empty check. |
|
||||
| `isMonthScraped(parkId, year, month, staleAfterMs)` | Checks if `MAX(scraped_at)` for a park-month is within the staleness threshold. Past months always return `true` (never re-scraped). |
|
||||
| `upsertRide()` | Insert or update a row in `rides`; bumps `last_seen` on every observation. |
|
||||
| `getRideBySlug(parkId, slug)` | Resolves a URL slug back to a canonical ride record via `idx_rides_slug`. |
|
||||
| `insertSample()` | `INSERT OR IGNORE` a sample into `ride_wait_samples` — idempotent on retries. |
|
||||
| `getRideSamplesForDay()` | Returns all samples for one ride on one local date (powers the Today chart). |
|
||||
| `getRideDailyAggregates()` | Per-day avg/max wait, avg/max Fast Lane, uptime %, and sample count over a window (powers the 7d / 30d charts). |
|
||||
| `countRideDays()` | Number of distinct `local_date` values for a ride in a window — used to decide whether 7d/30d tabs have enough data to render. |
|
||||
| `transact(fn)` | Wraps a function in a SQLite transaction for atomicity. |
|
||||
|
||||
### Storage
|
||||
|
||||
- **Location**: `backend/data/parks.db` (or `/app/backend/data/parks.db` in Docker)
|
||||
- **WAL journal files**: `parks.db-wal` and `parks.db-shm` accompany the main database
|
||||
- **Size**: Approximately 8,000-9,000 rows for a full year of 24 parks
|
||||
- **Size**:
|
||||
- `park_days`: ~8,000-9,000 rows for a full year of 24 parks
|
||||
- `rides`: ~1,000-1,500 rows total (a few dozen per park)
|
||||
- `ride_wait_samples`: grows daily during operating season; expect tens of thousands of rows per active day. Historical samples are retained — no automatic pruning is configured.
|
||||
- **Not committed to git**: Listed in `.gitignore`
|
||||
- **Auto-created**: The database and `data/` directory are created on first backend startup
|
||||
|
||||
@@ -335,6 +405,21 @@ interface ApiDay {
|
||||
- Handles buyouts: if `isBuyout` is true and it's not a passholder preview, the park is considered closed
|
||||
- Returns `{ date, isOpen, hoursLabel, specialType }`
|
||||
|
||||
### Six Flags Wait-Times API
|
||||
|
||||
Powers the Fast Lane wait number shown alongside the regular wait. Used by `lib/scrapers/sixflags-waittimes.ts` (`fetchFastLaneWaits`, `lookupFastLane`) and joined onto the Queue-Times rides by fuzzy name match.
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| URL | `https://d18car1k0ff81h.cloudfront.net/wait-times/park/{apiId}` (sibling of the operating-hours endpoint) |
|
||||
| Auth | None (spoofed browser headers, same as the operating-hours client) |
|
||||
| Timeout | 10 seconds |
|
||||
| Per-ride fields | `regularMinutes`, `fastLaneMinutes`, `hasFastLane` (`lookupFastLane()` return shape) |
|
||||
| Error handling | Returns `null` on any failure; the route falls back to Queue-Times' regular wait |
|
||||
| Backend cache | `fastLaneCache` (5-min TTL, in `services/live-cache.ts`) |
|
||||
|
||||
**Why two sources?** Queue-Times wait values lag at park open by ~10-15 minutes (parks haven't reported yet). The Six Flags wait-times feed updates earlier. When both sources have a wait for the same ride, the route prefers the Six Flags regular wait; Queue-Times remains the source of truth for `isOpen`. The Fast Lane number has no Queue-Times equivalent.
|
||||
|
||||
### Queue-Times.com API
|
||||
|
||||
Provides live ride open/closed status and wait times during park operating hours.
|
||||
@@ -384,16 +469,20 @@ Rides are classified as roller coasters using static data from the Roller Coaste
|
||||
|-----------|------|------|
|
||||
| `app/page.tsx` | Server | Fetches week data from backend, passes to HomePageClient |
|
||||
| `app/park/[id]/page.tsx` | Server | Fetches month + rides data in parallel |
|
||||
| `app/park/[id]/ride/[slug]/page.tsx` | Server | Fetches ride detail + today/7d/30d history in one call |
|
||||
| `HomePageClient` | **Client** | State management, auto-refresh, keyboard nav, localStorage |
|
||||
| `WeekCalendar` | Server | Desktop 7-column table layout |
|
||||
| `MobileCardList` | Server | Mobile card layout |
|
||||
| `ParkCard` | Server | Individual park card for mobile |
|
||||
| `ParkMonthCalendar` | Server | Month calendar grid |
|
||||
| `LiveRidePanel` | **Client** | Live ride list with coaster filter toggle |
|
||||
| `LiveRidePanel` | **Client** | Live ride list with coaster filter + Fast Lane toggle |
|
||||
| `WeekNav` | **Client** | Week navigation with arrow buttons |
|
||||
| `Legend` | Server | Status color legend |
|
||||
| `EmptyState` | Server | Empty database message |
|
||||
| `BackToCalendarLink` | **Client** | "Back" link using localStorage for last week |
|
||||
| `charts/WaitTimeTodayChart` | **Client** | Today's 5-min wait samples + outage shading (Recharts) |
|
||||
| `charts/WeeklyStatsChart` | **Client** | 7d / 30d daily aggregates chart (Recharts) |
|
||||
| `charts/UptimePill` | **Client** | Compact uptime % badge |
|
||||
|
||||
### Component Hierarchy
|
||||
|
||||
@@ -410,6 +499,12 @@ park/[id]/page.tsx (Server)
|
||||
├── BackToCalendarLink (Client)
|
||||
├── ParkMonthCalendar (Server)
|
||||
└── LiveRidePanel (Client) ........... or RideList (Server, inline)
|
||||
|
||||
park/[id]/ride/[slug]/page.tsx (Server)
|
||||
├── BackToCalendarLink (Client)
|
||||
├── UptimePill (Client)
|
||||
├── WaitTimeTodayChart (Client) ...... Today tab
|
||||
└── WeeklyStatsChart (Client) ........ 7d / 30d tabs
|
||||
```
|
||||
|
||||
### Client-Side Refresh
|
||||
@@ -496,4 +591,7 @@ interface Park {
|
||||
| Non-root containers | Both Docker images run as `nextjs` user (UID 1001) |
|
||||
| Backend-owned data | Frontend never contacts external APIs or the database directly |
|
||||
| CORS | Backend enables CORS middleware (currently unrestricted) |
|
||||
| Per-IP rate limit | `RATE_LIMIT_PER_MIN` (default 60) — fixed-window per-IP counter in `backend/src/middleware/rate-limit.ts`. Honours `x-forwarded-for`/`x-real-ip` so a reverse proxy doesn't collapse every client to one bucket. Over-limit requests return `429` with a `Retry-After` header. |
|
||||
| Env validation | `backend/src/config.ts` parses + validates env vars at startup; misconfiguration fails fast rather than surfacing in a request handler. |
|
||||
| Graceful shutdown | Backend listens for `SIGTERM`/`SIGINT`, closes the HTTP server and SQLite handle before exiting (force-exit timeout as a safety net). |
|
||||
| No secrets in frontend | `BACKEND_URL` is an internal Docker network address, not a secret |
|
||||
|
||||
Reference in New Issue
Block a user