Files
josh f87462385c
Build and Deploy / Lint, typecheck, test (push) Successful in 34s
Build and Deploy / Build & Push (push) Successful in 1m6s
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.
2026-06-02 15:31:50 -04:00

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 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:

{
    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 apiId is 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.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

# 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.