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.
12 KiB
Development
See also: Architecture | Operations | API Reference
Prerequisites
- Node.js 22+ and npm
- No database tools needed (SQLite is auto-created by the backend)
- No Docker needed for local development
Setup
# 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
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:
curl -X POST http://localhost:3001/api/scrape/trigger?scope=full
Terminal 2: Frontend
npm run dev
This starts the Next.js dev server on port 3000 with hot reload. Open http://localhost:3000.
Navigation:
- Use the
←/→buttons (or arrow keys) to navigate weeks; the selected week persists across visits via thetcWeekcookie - 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 toHomePageClient./park/[id](app/park/[id]/page.tsx) -- Park detail page. Fetches month calendar and live rides in parallel viaPromise.all. Live rides useapiFetch({ 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:
{
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:
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, navigate to the park, and note the numeric ID in the URL.
3. lib/coaster-data.ts
Add the coaster name set:
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). 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:
npm run debug -- --park <parkId> --date <YYYY-MM-DD>
Example:
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
apiIdis correct
Testing
Frontend and backend each have their own test suite, both using the Node built-in test runner.
Frontend tests
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
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.jsonfiles - Frontend uses
bundlermodule resolution with@/*path alias - Backend uses
CommonJSmodules 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 inapp/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
tsxfor runtime TypeScript execution (no build step in development)
Building Docker Images Locally
# 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.