A cycling advocacy organisation I work with wanted a platform to encourage people to cycle more during September. Riders would sign up, join a team or ride solo, and log their rides. The competition was one of the side attractions, with a leaderboard, weekly prizes and a scavenger hunt that sent people to local businesses. It has been a while since I built a public-facing product that people received with this much excitement. That response is the reason I wanted to write this down.## What the platform had to doI started with a short list of requirements. Everything else was optional.1. Riders log their rides through a form.
2. Riders take part in teams.
3. Riders see how they are doing, compared with others or with themselves.## What other people were doingBefore writing code I looked at what already existed. The closest match was [Go By Bike BC](https://gobybikebc.ca/). It had teams, manual ride logging, Strava sync, routes, reports, a community feed and Google sign-in.That confirmed the idea worked, and it gave me a feature list to measure against. The platform had to be for Halifax and not BC, so I got to work.## The stackWe had just about a month to launch, so I needed tools that would help me build quickly. The organisation's server already ran nginx, MySQL and Node, so I wanted something I could host on it. I chose Next.js with Prisma and MySQL. I ran the app under PM2 as a daemon.## One button for sign-up and loginI did not want a difference between signing up and logging in. The site has one "Get started" button. You enter your email, and the site checks whether an account exists. If it does, you go to login. If it doesn't, you go through sign-up.Both paths end with a six-digit code sent by email, so there are no passwords. People forget passwords and then have to reset them, and I wanted entry to be fast. I send the codes through Resend. I left out Google and Apple sign-in. Each OAuth provider is its own set of configuration and compliance work, and I did not have the time.## Logging a rideLogging a ride by hand takes three things, the date, the distance in kilometres and the purpose (commute, recreation, errand or other). That purpose field turned out to matter later, because it let me report that 55% of rides were commutes.Manual entry works, but most people won't keep typing in rides for a month. So the next job was automatic sync from the apps riders already use.## Cycling GuideThe organisation has a strong relationship with Cycling Guide, a cycling app, so connecting it was an easy decision. No integration existed, so I built one.The link works through a webview. The app opens my sign-in page. The rider enters their email and code, and a new rider fills out a short profile. The last screen hands a token back to the app, first through a JavaScript bridge and then, if that fails, through a universal link. I store only a hash of the token, and the app sees the real token once.After that, the app sends one request per finished ride with its own ride ID. If the app retries a request, the site updates the same ride and does not create a second one. A date outside the campaign window gets rejected. Disconnecting happens on my site, which invalidates the token. The app finds out the next time it calls and gets a 401. I wrote a full spec document for the Cycling Guide developers, so they had something to build against.## Strava and the ten-rider limitStrava is the most used cycling app, so it had to be in. I followed their guidelines for OAuth and applied for approval. New developer accounts on Strava can connect only ten riders until they are approved. The approval was supposed to take a week and took longer.The campaign started before it arrived. The ten slots filled, and riders who tried to connect landed on a Strava error page. That was a bad first impression for the people most eager to use the app.I replaced the Connect button with a waitlist. A rider clicked "Notify me" and I saved a timestamp on their account. When Strava approved the app a few days later, I emailed everyone on the list and turned the real button back on.## Ride with GPSRide with GPS got the same treatment. It uses OAuth and sends webhooks, and it excludes indoor and virtual rides, which matches Strava's policy. The first secret I used to check webhook signatures failed against real notifications on launch day, so I switched to the API secret. That was one of several things I could only learn by watching live traffic.## Webhooks, manual sync and the 30-day ruleBoth providers send webhooks. When a rider uploads an activity, Strava posts an event to an endpoint on the site. The handler looks up the integration by athlete ID, fetches the activity from the Strava API and upserts it as a ride. Strava retries on a non-200 response, so the handler returns 200 once the event is well-formed, even if the sync fails. Webhooks can get lost, so I added a manual resync button for riders and a cron route that re-polls stale integrations.OAuth comes with rules, and Strava's rule shaped the data model. When a rider disconnects, I have to delete their synced data within 30 days. Strava also tells me when someone revokes access from their side, so my webhook catches that and schedules the deletion. The rider's rides keep counting until the deadline. If they reconnect first, the deletion is cancelled. Each account can have only one sync source at a time, which stops the same ride from counting twice.## TeamsRiders create teams, share a join code or send invites. While planning this, I expected that some teams would outride others, so a single team leaderboard would have the same winners all month. I gave teams types, such as friend group, cycling club, business, school, health, government, non-profit and family. The leaderboard groups them into organizations, friends and family, and clubs. Each group gets its own corner to compete in.I applied the same idea to riders, with filters for kids, youth, adults and seniors. There are also separate categories, such as total kilometres, number of rides and most consecutive days. A rider who will never win the overall board can still lead a smaller one.## Family teamsI figured parents would add their kids, and some kids don't have phones. A Family team owner can add household members, with only a name and an age group, and log rides for them. Each family member is a real user row with no email and no login. Every total and leaderboard groups by user, so they all worked without changes.## The leaderboard arrowsThe up and down arrows next to each rider were the hardest feature in the build. They show whether your rank moved, and they went through several versions.The first version stored a snapshot. Whenever someone loaded the leaderboard and the last snapshot was more than five minutes old, the site saved every rider's and team's current numbers across seven metrics. To show an arrow, it found the snapshot from about an hour earlier, ranked those old numbers and compared the result with today's ranking. I stored the raw numbers and not the ranks, because rank depends on the filter. A rider's rank among all riders differs from their rank among adults.That table grew fast, because every page load past five minutes added a row for every rider and team on every metric. We reached more than 100,000 records. The arrows were also unreliable. A rider who moved from fifth to third saw an arrow for an hour, and then it disappeared, because "an hour ago" had caught up with the change. I got on a call with my friend Dela to look at it together, and the second version was his idea. It dropped snapshots. The database keeps one row per rider or team per metric, holding the last known rank and the current trend. When a rank changes, I update the row and set the trend. When it doesn't, the trend stays as it was, so an arrow lasts until the next real change. A new rider gets a silent first row with no arrow. It still has a catch. Each filter combination has its own state, and a combination nobody has opened yet starts with no arrows until it has been captured once. The capture also runs inside a page request, so a slow capture slows the page for whoever triggered it. I don't know that I got this right.## The scavenger huntFor the scavenger hunt, I list the participating businesses and generate a QR code for each. A rider scans the code at the location. The phone sends its GPS position, and the site computes the distance to the stop with the haversine formula. If the rider is close enough, the check-in counts. Each stop counts once per rider, and each check-in earned entries in the grand prize draw.## When the database pool filled upA Salesforce workshop I attended a few months earlier had covered database connections, and pooling came up as one of the solutions. That was the first thing I thought of when queries started timing out. I searched Stack Overflow and forums and could not find an answer that matched what I was seeing. Claude Code could not pin down what was happening either.The database pool is capped at five connections, and when it filled up, queries waited 10 seconds and then failed with a pool timeout. It did not recover on its own. My first fix was a watchdog that counted the timeouts. After three within 30 seconds, it exited the process so PM2 would restart it with a fresh pool. That was a bad idea.I changed it to rebuild the pool in place first and exit only if that kept failing. I never found what filled the pool, so the watchdog only limits how long an outage lasts.Riders also reported a timezone bug, which I wrote about in [another post](/posts/i-never-had-to-think-about-timezones).## PrivacyI did not want riders' names showing up in public. The database holds each rider's name and an approval status. For anyone else's view, the leaderboard shows a masked name such as "ey**m", which keeps about a third of the letters at the start and the last letter. A rider always sees their own full name. An admin can approve a name, and approved names show in full. Riders can also opt out of the leaderboard. Their kilometres still count toward their team's total. The ride data holds only distance, date and purpose, and never a route.## The result264 riders logged 3,963 rides across 28 teams. That adds up to 48,641 km, about 1.2 times around the Earth. 349 people made accounts, so about three in four logged at least one ride.55% of the rides were commutes, 33% were recreation and 10% were errands. 82% of rides happened on weekdays, and Tuesday and Wednesday were the busiest. Saturday had fewer rides but the longest, with more total kilometres than Monday, Thursday or Friday. Sept 9 was the busiest day with 216 rides. Sept 12 had the most distance, about 3,300 km, from only 133 rides. Three riders rode every day of the month.The grand prize draw had 3,990 entries from 257 people.Go By Bike Month comes back in June next year, and riders left a lot of feedback. I'm looking forward to building some new ideas that came from watching how people used the site and reading their feedback. The site is at [gobybikehalifax.ca](https://gobybikehalifax.ca).