f87462385c
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.
296 lines
12 KiB
Markdown
296 lines
12 KiB
Markdown
# Development
|
|
|
|
> See also: [Architecture](ARCHITECTURE.md) | [Operations](OPERATIONS.md) | [API Reference](API.md)
|
|
|
|
## Prerequisites
|
|
|
|
- **Node.js 22+** and **npm**
|
|
- No database tools needed (SQLite is auto-created by the backend)
|
|
- No Docker needed for local development
|
|
|
|
---
|
|
|
|
## Setup
|
|
|
|
```bash
|
|
# Clone the repository
|
|
git clone <repo-url>
|
|
cd ThoosieCalendar
|
|
|
|
# Install frontend dependencies
|
|
npm install
|
|
|
|
# Install backend dependencies
|
|
cd backend && npm install && cd ..
|
|
```
|
|
|
|
---
|
|
|
|
## Running Locally
|
|
|
|
The project requires two terminals -- one for the backend, one for the frontend.
|
|
|
|
### Terminal 1: Backend
|
|
|
|
```bash
|
|
cd backend
|
|
npm run dev
|
|
```
|
|
|
|
This starts the Hono API server on port 3001 using `tsx` (TypeScript runtime with watch mode). On first run:
|
|
- Creates an empty SQLite database at `backend/data/parks.db`
|
|
- Registers the four-tier cron scheduler
|
|
- The schedulers will populate data automatically over time, or you can trigger a manual scrape immediately:
|
|
|
|
```bash
|
|
curl -X POST http://localhost:3001/api/scrape/trigger?scope=full
|
|
```
|
|
|
|
### Terminal 2: Frontend
|
|
|
|
```bash
|
|
npm run dev
|
|
```
|
|
|
|
This starts the Next.js dev server on port 3000 with hot reload. Open [http://localhost:3000](http://localhost:3000).
|
|
|
|
**Navigation:**
|
|
- Use the `←` / `→` buttons (or arrow keys) to navigate weeks; the selected week persists across visits via the `tcWeek` cookie
|
|
- Click any park name to open its detail page with month calendar and ride status
|
|
|
|
---
|
|
|
|
## Project Structure Walkthrough
|
|
|
|
### `app/` -- Next.js Pages
|
|
|
|
Three routes:
|
|
- `/` (`app/page.tsx`) -- Home page. Server component that fetches week data from the backend and passes everything to `HomePageClient`.
|
|
- `/park/[id]` (`app/park/[id]/page.tsx`) -- Park detail page. Fetches month calendar and live rides in parallel via `Promise.all`. Live rides use `apiFetch({ noStore: true })` to bypass the Next.js Data Cache.
|
|
- `/park/[id]/ride/[slug]` (`app/park/[id]/ride/[slug]/page.tsx`) -- Per-ride detail page with Today / 7d / 30d wait-time history. All three tabs render from a single backend response (no client-side range fetches).
|
|
|
|
Top-level boundaries: `app/error.tsx` (root error UI), `app/not-found.tsx`, `app/park/[id]/error.tsx`, and `app/loading.tsx` (streaming skeleton).
|
|
|
|
### `components/` -- React Components
|
|
|
|
| Component | Type | Purpose |
|
|
|-----------|------|---------|
|
|
| `HomePageClient` | Client | Top-level state: coaster filter, auto-refresh, keyboard nav |
|
|
| `WeekCalendar` | Server | Desktop 7-column table with region groupings |
|
|
| `MobileCardList` | Server | Mobile card layout (below `lg` breakpoint) |
|
|
| `ParkCard` | Server | Individual park card for mobile |
|
|
| `ParkMonthCalendar` | Server | Month grid for park detail page |
|
|
| `LiveRidePanel` | Client | Live ride list with coaster toggle, Fast Lane toggle, wait times |
|
|
| `WeekNav` | Client | Week navigation arrows |
|
|
| `Legend` | Server | Color legend for status indicators |
|
|
| `EmptyState` | Server | Empty database message |
|
|
| `BackToCalendarLink` | Client | Back link using localStorage for last week |
|
|
| `charts/WaitTimeTodayChart` | Client | Today's 5-min wait samples with outage shading (Recharts) |
|
|
| `charts/WeeklyStatsChart` | Client | 7d / 30d daily aggregate chart (Recharts) |
|
|
| `charts/UptimePill` | Client | Compact uptime % badge |
|
|
|
|
### `lib/` -- Shared Code
|
|
|
|
Imported by both frontend and backend:
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `types.ts` | Core `DayData` interface |
|
|
| `env.ts` | `getTodayLocal()` (3 AM switchover), `isWithinOperatingWindow()`, `getOperatingStatus()`, `parseStalenessHours()` |
|
|
| `parks.ts` | All 24 park definitions, `PARK_MAP`, `groupByRegion()` |
|
|
| `coaster-data.ts` | Static RCDB coaster name sets per park, `getCoasterSet()` |
|
|
| `coaster-match.ts` | `normalizeForMatch()`, `isCoasterMatch()` -- fuzzy name matching |
|
|
| `queue-times-map.ts` | `QUEUE_TIMES_IDS` -- park ID to Queue-Times park ID mapping |
|
|
| `api.ts` | `apiFetch<T>()` -- typed fetch helper with `revalidate` or `noStore` option |
|
|
| `outage.ts` | `computeOutages()` -- detects contiguous closed-during-hours runs for the today chart |
|
|
| `ride-slug.ts` | `slugifyRideName()` -- URL slug used by `/park/[id]/ride/[slug]` and the `rides` table |
|
|
| `timezone.ts` | `formatLocalDate()`, `formatLocalTime()` for bucketing samples in a park's IANA tz |
|
|
| `scrapers/sixflags.ts` | Six Flags CloudFront operating-hours client -- `scrapeMonth()`, `fetchToday()`, `scrapeRidesForDay()`, rate limiting |
|
|
| `scrapers/sixflags-waittimes.ts` | Six Flags Fast Lane wait-times client -- `fetchFastLaneWaits()`, `lookupFastLane()` |
|
|
| `scrapers/queuetimes.ts` | Queue-Times.com API client -- `fetchLiveRides()` |
|
|
| `scrapers/log.ts` | Shared scraper logger (used by both `sixflags.ts` and `sixflags-waittimes.ts`) |
|
|
| `scrapers/types.ts` | `Park`, `DayStatus`, `MonthCalendar`, `ScraperAdapter` interfaces |
|
|
|
|
### `backend/src/` -- Hono API Server
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `index.ts` | Entry point -- middleware (request log, CORS, rate limit), route registration, DB init, scheduler start, graceful shutdown |
|
|
| `config.ts` | Env-validated config object (`PORT`, `RATE_LIMIT_PER_MIN`, `PARK_HOURS_STALENESS_HOURS`, `NODE_ENV`). Fails fast on bad input. |
|
|
| `log.ts` | Structured logger -- emits `[ISO] [LEVEL] [tag] msg key=value` lines. No external dep. |
|
|
| `db/index.ts` | SQLite connection singleton, schema for `park_days` / `rides` / `ride_wait_samples`, WAL mode |
|
|
| `db/queries.ts` | All SQL queries -- `upsertDay`, `getDateRange`, `isMonthScraped`, `upsertRide`, `getRideBySlug`, `insertSample`, `getRideSamplesForDay`, `getRideDailyAggregates`, `countRideDays`, `getParkDayCount`, `transact` |
|
|
| `middleware/rate-limit.ts` | Fixed-window per-IP limiter. Honours `x-forwarded-for` / `x-real-ip`. Returns 429 with `Retry-After`. |
|
|
| `routes/calendar.ts` | `/api/calendar/*` -- week and month data with live today merging |
|
|
| `routes/parks.ts` | `/api/parks/*` -- park metadata |
|
|
| `routes/rides.ts` | `/api/parks/:id/rides` -- live ride status + Fast Lane join + schedule fallback |
|
|
| `routes/ride-history.ts` | `/api/parks/:id/rides/:slug` -- ride detail + today/7d/30d history in one payload |
|
|
| `routes/status.ts` | `/api/status` -- health check |
|
|
| `routes/scrape.ts` | `/api/scrape/trigger` -- manual scrape |
|
|
| `services/scheduler.ts` | Five-tier cron registration with per-tier `withLatch` concurrency guards; startup-scrape-when-empty check |
|
|
| `services/scraper.ts` | Scraping orchestration -- `scrapeToday()`, `scrapeMonths()`, `scrapeFullYear()` |
|
|
| `services/wait-sampler.ts` | Tier-5 5-minute sampler -- joins Queue-Times + Fast Lane, writes `ride_wait_samples`, skips weather-delayed parks |
|
|
| `services/live-cache.ts` | Shared `TtlCache<T>` instances (`liveRidesCache`, `fastLaneCache`) so the rides route, the ride-history route, and the Tier-5 sampler share warmed upstream data |
|
|
| `services/cache.ts` | Generic `TtlCache<T>` class with configurable TTL |
|
|
|
|
---
|
|
|
|
## Adding a New Park
|
|
|
|
Adding a park requires changes to three files. The park will be automatically picked up by the scheduler, the API, and the frontend.
|
|
|
|
### 1. `lib/parks.ts`
|
|
|
|
Add an entry to the `PARKS` array:
|
|
|
|
```typescript
|
|
{
|
|
id: "newpark", // URL-safe unique identifier
|
|
apiId: 123, // Six Flags CloudFront API park ID
|
|
name: "Six Flags New Park",
|
|
shortName: "New Park",
|
|
chain: "sixflags",
|
|
slug: "newpark", // Should match sixflags.com URL path
|
|
region: "Midwest", // One of: Northeast, Southeast, Midwest, Texas & South, West & International
|
|
location: {
|
|
lat: 40.0,
|
|
lng: -80.0,
|
|
city: "Anytown",
|
|
state: "OH",
|
|
},
|
|
timezone: "America/New_York", // IANA timezone
|
|
website: "https://www.sixflags.com",
|
|
},
|
|
```
|
|
|
|
**Finding the API ID:** Use the debug script or inspect network requests on the Six Flags website. The `apiId` is the numeric park identifier in the CloudFront API URL.
|
|
|
|
### 2. `lib/queue-times-map.ts`
|
|
|
|
Add the Queue-Times.com park ID mapping:
|
|
|
|
```typescript
|
|
export const QUEUE_TIMES_IDS: Record<string, number> = {
|
|
// ... existing mappings
|
|
newpark: 456, // Queue-Times park ID
|
|
};
|
|
```
|
|
|
|
**Finding the Queue-Times ID:** Browse [queue-times.com](https://queue-times.com), navigate to the park, and note the numeric ID in the URL.
|
|
|
|
### 3. `lib/coaster-data.ts`
|
|
|
|
Add the coaster name set:
|
|
|
|
```typescript
|
|
export function getCoasterSet(parkId: string): Set<string> | null {
|
|
// ... existing cases
|
|
case "newpark":
|
|
return new Set([
|
|
normalizeForMatch("Coaster Name One"),
|
|
normalizeForMatch("Coaster Name Two"),
|
|
]);
|
|
}
|
|
```
|
|
|
|
**Finding coaster names:** Look up the park on [RCDB (Roller Coaster Database)](https://rcdb.com). List all operating roller coasters. Names should be the official RCDB names before normalization.
|
|
|
|
---
|
|
|
|
## Debug Script
|
|
|
|
Inspect raw API data and parsed output for any park and date:
|
|
|
|
```bash
|
|
npm run debug -- --park <parkId> --date <YYYY-MM-DD>
|
|
```
|
|
|
|
**Example:**
|
|
|
|
```bash
|
|
npm run debug -- --park kingsisland --date 2026-06-15
|
|
```
|
|
|
|
This fetches the raw Six Flags API response for the park and date, displays the parsed result, and saves the raw JSON to the `debug/` directory for inspection. Useful for:
|
|
- Investigating API response format changes
|
|
- Debugging parsing issues for specific parks/dates
|
|
- Verifying that a park's `apiId` is correct
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
Frontend and backend each have their own test suite, both using the Node built-in test runner.
|
|
|
|
### Frontend tests
|
|
|
|
```bash
|
|
npm test
|
|
```
|
|
|
|
Test files live in `tests/`:
|
|
|
|
| File | Coverage |
|
|
|------|----------|
|
|
| `tests/coaster-matching.test.ts` | `isCoasterMatch()` — exact, prefix, compact, conjunction rejection |
|
|
| `tests/fast-lane-matching.test.ts` | `lookupFastLane()` — name normalization and Fast Lane join logic |
|
|
| `tests/outage-detection.test.ts` | `computeOutages()` — contiguous-closed-run detection for the today chart |
|
|
| `tests/ride-slug.test.ts` | `slugifyRideName()` — URL slug generation and stability |
|
|
| `tests/timezone-bucketing.test.ts` | `formatLocalDate()` / `formatLocalTime()` — DST-safe park-tz bucketing |
|
|
|
|
### Backend tests
|
|
|
|
```bash
|
|
cd backend && npm test
|
|
```
|
|
|
|
Test files live in `backend/tests/`:
|
|
|
|
| File | Coverage |
|
|
|------|----------|
|
|
| `backend/tests/wait-aggregation.test.ts` | SQL aggregation in `getRideDailyAggregates()` — averages, max, uptime, sample count |
|
|
|
|
---
|
|
|
|
## Code Conventions
|
|
|
|
### TypeScript
|
|
|
|
- **Strict mode** enabled in both `tsconfig.json` files
|
|
- Frontend uses `bundler` module resolution with `@/*` path alias
|
|
- Backend uses `CommonJS` modules with `@lib/*` alias resolving to `../lib/*`
|
|
|
|
### Styling
|
|
|
|
- **Inline styles** via `style={{}}` props for most component styling
|
|
- **Tailwind CSS v4** for responsive utilities (`hidden lg:block`, `sm:flex`, `px-4 sm:px-6`)
|
|
- Theme defined via `@theme {}` block and CSS custom properties in `app/globals.css`
|
|
- No CSS modules, no styled-components, no component library
|
|
|
|
### Code Organization
|
|
|
|
- Shared types and utilities live in `lib/` and are imported by both frontend and backend
|
|
- No component library -- all UI is built from scratch
|
|
- Backend uses `tsx` for runtime TypeScript execution (no build step in development)
|
|
|
|
---
|
|
|
|
## Building Docker Images Locally
|
|
|
|
```bash
|
|
# Build the web image
|
|
docker build --target web -t thoosiecalendar:web .
|
|
|
|
# Build the backend image
|
|
docker build --target backend -t thoosiecalendar:backend .
|
|
|
|
# Run locally with Docker Compose
|
|
docker compose up -d
|
|
|
|
# Or run individual containers
|
|
docker run -d -p 3001:3001 -v park_data:/app/backend/data -e TZ=America/New_York thoosiecalendar:backend
|
|
docker run -d -p 3000:3000 -e BACKEND_URL=http://host.docker.internal:3001 thoosiecalendar:web
|
|
```
|
|
|
|
When running individual containers outside of Docker Compose, use `host.docker.internal` instead of `backend` for the `BACKEND_URL`, since Docker's internal DNS won't resolve service names without Compose.
|