← Back to projects

Personal Project Case Study

ParkPulse

ParkPulse predicts how busy a UK theme park will be on any given day and turns that prediction into a personalised, weather-aware itinerary.

ParkPulse
ParkPulse secondary view

Product focus

UK theme park crowd prediction, multi-park comparison, and a personalised, weather-aware day planner.

Architecture

TypeScript monorepo with an Express + Zod prediction API, a React + Vite + Tailwind planner UI, and a self-calibrating prediction log.

Build priority

Transparent, explainable predictions over black-box scoring — every crowd index ships with a weighted factor breakdown and a confidence value.

TypeScriptReactNode.jsExpressTailwind CSSDaisyUIViteResponsive build

Full Project Overview

ParkPulse is a planning tool for UK theme park visitors who want to pick the quietest day, the right park, and the smartest order to ride attractions in.

Instead of guessing whether a Tuesday in October will be quieter than the Saturday after, ParkPulse takes a park and date and returns a 0–100 crowd index, a confidence value, an hourly crowd curve, per-ride queue estimates, and a personalised itinerary for the day.

The product spans three planning modes — single date, date range, and multi-park — all backed by the same scoring engine, so a user can move from "is this day busy?" to "which day in this window is quietest?" to "which park should I pick?" without changing tools.

  • predict crowd levels for a specific park and date
  • rank a window of dates from quietest to busiest
  • compare all four parks on the same date
  • generate a personalised, weather-aware ride itinerary
  • log every prediction and self-calibrate from recorded actuals

Problem It Solves

UK theme park visitors currently rely on rough rules of thumb — "avoid school holidays", "pick a Tuesday", "check the weather" — without any tool that combines those signals into a single, defensible prediction for a specific park on a specific date.

ParkPulse solves that by unifying operating calendars, school and bank holidays, weather forecasts, seasonal events, ride launches, and pricing tiers into one weighted scoring engine, then exposing every contributing factor so the user can see why a day was scored the way it was.

  • crowd index combines calendar, weather, holiday, event, and ride-launch signals into one score
  • date-range mode ranks every day in a window so users can pick the quietest one
  • multi-park mode picks the best park to visit on a fixed date
  • itinerary planner converts the prediction into an actual order of rides
  • every prediction is explained with weighted factors instead of a black-box score

Core Features

Single-Date Prediction

  • 0–100 crowd index with a confidence value
  • Operating status detection (open, closed, likely-closed, event-only)
  • Weighted factor breakdown so every score is explainable
  • Per-category queue bands (thrill, family, water, indoor, children)
  • Pricing tier estimate (off-peak, standard, peak, super-peak)

Date-Range And Multi-Park Comparison

  • Rank every date in a chosen window from quietest to busiest
  • One-click jump from a ranked date into its full prediction
  • Compare all four parks on the same date in a single view
  • Highlights the best park or best day automatically

Hourly Crowd Curve

  • Hour-by-hour crowd multiplier across the operating window
  • Detected peak hour and quietest hour for the day
  • Strategic advice (e.g. "hit headline rides first thing", "use the lunch lull")

Per-Ride Queue Estimates

  • Estimated wait minutes and queue band per ride
  • Single-rider queue estimates where supported
  • Adjusts for school holidays and rainy-day demand redistribution

Personalised Itinerary Planner

  • Day type presets — everything, thrill seeker, family, kids
  • Entrance choice — main gate or hotel entrance
  • Pace control — relaxed, normal, or packed
  • Ease-into-the-day option that delays headline rides
  • Configurable lunch break hour
  • Skip-list and must-ride list by ride name
  • Single-rider preference toggle
  • Hour-by-hour slots with per-ride wait, duration, and tip

Self-Calibrating Prediction Log

  • Every prediction is appended to a JSONL log
  • Endpoint to record observed actual crowd index for a park/date
  • Accuracy stats endpoint exposes mean error over time
  • Calibration multipliers feed back into future scoring

Premium Tier Gating

  • Token-based premium middleware on the Express API
  • Free tier exposes prediction, queue bands, and hourly curve
  • Premium tier unlocks per-ride estimates and full itinerary planning
  • Designed to slot into a real payment provider later without route changes

Product Philosophy

ParkPulse was built around a small set of explicit product principles.

  • Predictions must be explainable, not magical — every score ships with its weighted factors.
  • Confidence is a first-class value, not a hidden assumption.
  • Planning beats prediction — a number is only useful if it produces a better day.
  • Calibration matters — the system must learn from recorded actuals over time.
  • Personalisation should be opinionated but overridable through skip and must-ride lists.

Technical Overview

ParkPulse is a TypeScript monorepo organised as an npm workspace with a server (Express prediction API) and a web (React + Vite planner UI) package.

The API exposes a small surface of typed POST and GET endpoints under /api/predictions, validates every request body with Zod, and composes its predictions from a set of focused service modules — one per signal — that each contribute weighted factors to the final score.

The web app is a single-page React 18 + Vite application styled with Tailwind CSS and DaisyUI, using Radix Themes and Lucide icons for primitives. It drives all three planning modes (single date, date range, multi-park) from one UI shell.

Architecture Summary

Frontend

  • React 18 + Vite + TypeScript
  • Tailwind CSS 3 with DaisyUI components
  • Radix UI Themes and Radix Icons
  • Lucide React for additional iconography
  • Single-page planner with tabbed single / range / compare modes

Backend And Data Layer

  • Express 4 API with CORS and JSON middleware
  • Zod schemas validate every request body before logic runs
  • Feature-per-file service modules under server/src/services
  • Prediction log persisted as JSONL for transparent calibration
  • Vercel serverless adapter (api/index.ts) re-exports the same Express app

Prediction Engine

The scoring engine combines a per-park baseline with weighted contributions from each signal module, then applies learned calibration multipliers before returning a 0–100 crowd index, a confidence value, and a factor breakdown.

  • bankHolidays — England and Wales bank holiday detection
  • schoolHolidays — multi-region UK school term modelling
  • weather + climateNormals — short-range forecast and 10-year fallback
  • parkEvents — Scarefest, Fright Nights, and seasonal uplift
  • rideLaunches — new headline ride demand boost
  • operatingCalendar — open / closed / event-only status
  • ticketPricing — pricing tier as both signal and output
  • daylight — daylight hours as a soft demand signal
  • historicalComparison — comparison against same-period prior years
  • weightCalibration — applies multipliers learned from actuals
  • hourlyCurve — derives the hourly crowd shape
  • rideDatabase + itineraryPlanner — per-ride queues and day plan

Premium Gating

  • Token-based middleware (extractPremiumTier, requirePremium)
  • Tokens read from PREMIUM_TOKENS env var with a sensible dev default
  • stripPremiumFields helper for downgrading responses cleanly
  • Designed so premium logic lives in middleware, not in route bodies

Tech Stack

Core Languages And Runtimes

  • TypeScript 5
  • Node.js (Express runtime + Vercel serverless target)
  • npm workspaces monorepo layout

Backend

  • Express 4
  • Zod for request validation
  • CORS and dotenv
  • ts-node-dev for fast iteration
  • Vitest for unit testing

Frontend

  • React 18 + React DOM
  • Vite 5 with @vitejs/plugin-react-swc
  • Tailwind CSS 3 + DaisyUI 5
  • Radix UI Themes + Radix Icons
  • Lucide React

Tooling And Deployment

  • Vercel for hosting (static web build + serverless API entrypoint)
  • PostCSS + Autoprefixer
  • JSONL prediction log for calibration data

Notable Engineering Decisions

One Service Module Per Signal

Every demand signal — bank holidays, school holidays, weather, events, ride launches, daylight, pricing — lives in its own typed module under server/src/services. The scoring module composes them, which keeps each signal independently testable and makes it cheap to add or replace one without touching the rest.

Explainable Predictions By Default

Rather than returning a single number, every prediction ships with an explanation containing a summary and a list of weighted PredictionFactor objects. The API never produces a score without also producing the reasons for that score.

Self-Calibration From A JSONL Log

A POST /record-actual endpoint lets observed crowd levels be matched back to the prediction that produced them. The prediction log feeds a calibration module that adjusts future weight multipliers, so the system improves with use rather than depending on a one-shot training run.

Itinerary As A First-Class Output

A crowd index alone does not help a visitor plan their day. The itinerary planner consumes the same prediction (crowd index, hourly curve, weather, school-holiday flag) plus user preferences and produces a slot-by-slot ride plan, which is what the user actually came for.

Single Express App, Two Deployment Targets

The Express app is exported from server/src/app.ts and consumed both by the local dev server (server/src/index.ts) and by a Vercel serverless function (api/index.ts that simply re-exports the app). vercel.json rewrites /api/* and /health to that function and serves the Vite build for everything else, so one app definition powers both environments.

Premium As Middleware, Not Branching Logic

Premium gating is implemented as Express middleware with a stripPremiumFields helper, so route handlers stay focused on prediction logic and the gating model can be swapped for a real payment provider without rewriting endpoints.

What I Built

I designed and built ParkPulse end-to-end: the product concept, the source register and ingestion plan, the weighted scoring model, the per-signal service modules, the Express + Zod API, the React + Vite planner UI, the itinerary planner, the calibration loop, and the Vercel deployment configuration.

The work spans product strategy (deciding which signals matter and how to combine them), backend engineering (typed services, validation, logging, and serverless deployment), and frontend engineering (a single-page planner that exposes three different planning modes through one cohesive UI).

Key Challenges Solved

Combining Many Weak Signals Into One Defensible Score

No single input — weather, school holidays, events — predicts crowds on its own. The challenge was assembling a weighted model where each signal contributes a transparent, bounded factor, and where the final crowd index can be defended by pointing at the factor breakdown rather than at a black-box number.

Going From Prediction To Plan

Predicting a crowd index is only half the job; the user actually wants to know which ride to do at 10am. The itinerary planner had to consume the hourly curve, weather, school-holiday context, and a small set of user preferences (day type, pace, ease-in, lunch break, skip and must-ride lists) and produce a coherent slot-by-slot plan.

Closing The Loop With Calibration

Static weights drift. Building a JSONL prediction log, a record-actual endpoint, and a weight-calibration module meant the system could learn from observed reality instead of depending on a one-time tuning pass.

One Codebase, Local Dev And Serverless

The Express app needed to run as a normal Node server during development and as a Vercel serverless function in production without duplicating route definitions. Splitting app, server entrypoint, and serverless adapter into three thin files (app.ts, index.ts, api/index.ts) solved that cleanly.

Quality And Reliability

Quality work in ParkPulse is concentrated in the logic-heavy parts of the prediction engine, where errors are hardest to spot by eye.

  • Vitest unit tests for hourlyCurve, operatingCalendar, rideDatabase, rideLaunches, schoolHolidays, scoring, and ticketPricing
  • Zod request validation on every API endpoint
  • Typed PredictionFactor and SingleDatePrediction shapes shared between API and UI
  • Per-park baselines and bounded factor weights to prevent runaway scores
  • Health endpoint at /health for uptime monitoring
  • JSONL prediction log enabling after-the-fact accuracy analysis

Deployment And Operations

ParkPulse is set up for low-cost public deployment on Vercel, with the static planner and the Express API served from the same project.

A vercel.json file rewrites /api/* and /health to the serverless adapter and serves the Vite build for everything else, so the whole product ships as a single deployment.

  • Vercel deployment configured via vercel.json (build, output, rewrites)
  • /health endpoint for uptime checks
  • PREMIUM_TOKENS environment variable for premium-tier access
  • Local dev split across npm run dev:api (port 4000) and npm run dev:web (port 5173)
  • Source register (docs/source-register.md) tracking every planned data source and its approval status

What I Would Build Next

  • Live ingestion of the planned sources in the source register (operating calendars, planned closures, events) to replace the current modelled defaults
  • A historical accuracy dashboard surfacing the JSONL prediction log and calibration trends in the UI
  • User accounts so saved itineraries, must-ride lists, and recorded actuals can persist across visits
  • Mobile-first itinerary view with on-the-day check-offs and live re-planning when a ride goes down
  • Expansion beyond the four MVP parks to additional UK and European destinations