Live: https://calcpace.app
A sports activity tracker for runners and cyclists with a public pace Calculator and unit Converter — built to practice modern Rails infrastructure and deployment. This app serves as a production dogfooding environment for the open-source calcpace gem.
- Master VPS deployment with Kamal + Docker on DigitalOcean (vs. Heroku/PaaS)
- Practice TDD with Minitest and CI/CD with GitHub Actions
- Follow 37signals/Basecamp design principles — thin controllers, rich models, Current attributes
| Layer | Technology |
|---|---|
| Backend | Ruby on Rails 8 |
| Frontend | ERB + Tailwind CSS |
| Database | PostgreSQL 17 |
| Cache / Jobs | Redis + Sidekiq |
| Resend API | |
| Monitoring | AppSignal (APM + error tracking) |
| Deploy | Kamal + Docker |
| Hosting | DigitalOcean (VPS) |
| File Storage | Cloudflare R2 (Active Storage — S3-compatible) |
| Maps | Leaflet.js + OpenStreetMap (no API key required) |
- Ruby 3.4.4 (via asdf)
- Docker + Docker Compose
- PostgreSQL client (
libpq)
# 1. Clone and install dependencies
git clone https://github.com/0jonjo/calcpace_web.git
cd calcpace_web
bundle install
# 2. Start PostgreSQL and Redis via Docker
docker compose up -d
# 3. Create and migrate the database
bin/rails db:create db:migrate
# 4. Start the development server (Rails + Tailwind watcher)
bin/devOpen http://localhost:3000.
Note: A system Redis on port 6379 will conflict. The compose file maps Redis to port 6380 to avoid it.
bin/rails testThe calculator and converter are publicly accessible at calcpace.app — no login required:
Compute running and cycling metrics:
- Pace — given distance and total time
- Total Time — given distance and pace
- Distance — given total time and pace
- Race Predictor — estimate finish time for standard race distances (5K, 10K, half marathon, marathon) from a known result
Supports both metric (km) and imperial (mi) unit systems.
Convert between common sports units:
- Distance — km ↔ mi
- Speed — km/h ↔ mph
- Pace — min/km ↔ min/mi
All calculations are powered by the calcpace gem.
A guest account with sample activities (a run and a bike ride) is available to explore the app:
- Email:
guest@calcpace.app - Password: set via
GUEST_PASSWORDenv var in production
The guest profile is automatically reset every Monday at 3am by the GuestResetJob.
Registration is invite-only. To create an invite and send the email in production:
kamal app exec --reuse "bin/rails invite:create[person@example.com]"Output:
Invite created and sent!
Email : person@example.com
Code : BETA-X7K2P9
Expires : 2026-04-27 (30 days)
Link : https://calcpace.app/registration/new?invite_code=BETA-X7K2P9
The invite email is sent automatically via Resend. The link pre-fills the invite code on the registration form. Codes expire after 30 days and are single-use.
To create an account locally, generate an invite first:
bin/rails invite:create[person@example.com]Then open the pre-filled link printed in the console (or visit http://localhost:3000/registration/new and enter the code manually).
Handled by Resend (free tier: 3,000 emails/month). All emails are queued via Sidekiq.
| Event | |
|---|---|
| Invite sent | Invite code + pre-filled registration link |
| Account created | Welcome email |
| Password reset requested | Reset link (expires in 15 min) |
| Password changed | Security notification |
Activities support an optional .gpx file upload (stored in Cloudflare R2). When a GPX file is attached:
GpxParseJobruns asynchronously via Sidekiq- The job parses trackpoints from the file using the
gpxgem - Trackpoints are bulk-inserted into the
trackpointstable - Distance and elevation gain are calculated via the
calcpacegem (TrackCalculatormodule — Haversine formula) and stored on the activity
The activity show page (both logged-in and public) renders an interactive OpenStreetMap map via Leaflet.js and a per-km splits table, calculated on-the-fly from the stored trackpoints.
Users can opt in to a public profile at /:username. Each activity can individually be set to public or private. Public activities are listed on the profile page and have a dedicated public detail page at /:username/activities/:id.
Profile avatars and activity photos are stored in Cloudflare R2 via Active Storage. Supported formats: JPEG, PNG, WebP (max 5 MB). GPX files: XML format (max 20 MB).
Sidekiq processes the job queue using the Redis accessory. A separate worker container runs alongside the web container in production (see config/deploy.yml).
| Job | Trigger | Description |
|---|---|---|
GuestResetJob |
Every Monday at 3am (sidekiq-cron) | Wipes guest user activities and recreates the profile |
GpxParseJob |
On GPX file upload | Parses trackpoints, calculates distance/elevation, bulk-inserts into DB |
GitHub Actions runs the full test suite on every push to main. Deploys are triggered by GitHub Releases. See .github/workflows/.
Deployment is handled by Kamal targeting a DigitalOcean VPS. See config/deploy.yml.