← Manuel Hitz, CV

Rides

Personal project, built with Claude Code · 2026

Rides is a small web app, plus an iOS companion app, that replaces my use of Strava. It shows every ride I have recorded since 2014 on one map, with a heatmap, filters, statistics, ride details, elevation corrections and lift detection. I built it with Claude Code, as a hands-on way to find out how far agentic coding has come.

Why replace Strava

I have been using Strava to record all my rides since 2014. For a long time, my phone was recording straight to Strava. During the last 5 years, I have been using an Apple Watch to record the rides to Apple Health as cycling workouts, then imported into Strava.

A prominent feature of Strava is that its users can define trail/road segments, and compete on them. If your ride is publicly shared, you compete with the whole world (split by gender, only 2 genders). You get a Top 10, culminating in a KOM/QOM: King/Queen of Mountain. If your ride is private, you only compete against yourself. I have defined a bunch of segments, especially during my time in Lausanne (2018–2021).

I named a pretty flat segment that I liked because it is slaloming between conifers “Sapinette”, as a callback to Flav’s homemade pine tree flavored liquor. I still hold the KOM, most likely only because it’s not a popular segment.

Strava segment Sapinette on a map north of Lausanne
A segment north of Lausanne I named Sapinette

Another segment I named “Rooty McRootface“ because of Boaty McBoatface. I also still hold the KOM, probably for the same reason as on Sapinette.

Strava segment Rooty McRootface on a map
A small segment with lots of roots, also north of Lausanne

And I have played the game of trying to improve segment times, both publicly and privately.

I used to ride the segment named “The full Wiedholz“ a lot during my hardtail years in Zurich, and really wanted to get the KOM. I did on 22.09.2017, and am still barely in the Top 10 now.

Strava segment The full Wiedholz on a map
A narrow, winding trail east of Zurich

Leiterli, a.k.a. “Uetliberg ridge singletrack descent”, is still my favorite downhill trail in Zurich. One afternoon in March 2017 I decided to tackle it with my DH bike instead of my hardtail, with the goal of getting the KOM. And I did! It is a very popular segment though, and I’m not even in the Top 10 anymore.

Strava segment Leiterli on a map
A very steep downhill trail, south-east of Zurich

A couple of days after I got the KOM on Leiterli, the dethroned king reclaimed the first place 😅

Strava notification that the Leiterli KOM was lost
Some KOMs are hot

I very recently got a Top 10 on my favorite descent in my home region, Gruyère.

Strava segment in Gruyère on a map
Very steep trail best ridden when dry

Overall, I have been a happy user of the free version of the app. For a while though, some things have bothered me about Strava:

I have been thinking about creating a small web app that could replace my usage of Strava, getting rid of all the bloat I don’t care about, and implementing the handful of features I actually want. So, after finally using Claude Code again for a coding assignment I had to do for a job application, I realized how much progress agentic coding has made in the last months. It gave me the necessary little nudge of motivation to start building my Strava replacement.

The Rides web app: search, filters, stats and a list of rides next to a map of Switzerland covered in red tracks
Rides, my Strava replacement

Features

I wanted to keep the app as simple as possible, while still having all the features I came to like on Strava, and the ones Strava could not provide.

Everything is built around the map

By default, the app shows multiple rides at once on the map. Every ride can be selected on the map to view its details.

All rides of 2025 shown on the map
All rides of 2025 on the map

When a track is clicked on the map, and there are several rides going through that location, a popup lists those rides for selection:

Popup listing 38 rides at the clicked location
Multiple rides at the same location

One of the features I really wanted to have, and that was super easy to implement, is a heatmap: lighter lines on the map are ridden less often, darker/brighter lines are traveled more frequently.

Heatmap of rides around Zurich on a dark map
Heatmap centered around Zurich
Heatmap of rides around Lausanne on a dark map
Heatmap centered around Lausanne

The map can be centered around the current location. This is most useful on the phone, while out in the wild, when you’re not sure anymore where exactly that nice trail you’ve already ridden in the past is:

Map on a phone with a blue dot for the current location
Live location, most useful on mobile

Filtering and searching rides

The basics I wanted:

Rides filtered by search term, type, bike, date range and media, with the filters in the URL
Search and filters, reflected in the URL

As you can see in the screenshot, the search query and the filters are in the URL, so it can be shared. For example, ?q=Rico lists the rides with Rico, and ?bike=nemo the rides with my Cinelli gravel bike.

Statistics

Whether you are filtering or not, there is always a summary of the number of rides, total distance, total elevation and riding hours displayed:

959 rides, 29’113 km, 513’457 m climbed, 1’696 hours
Overall stats

If you click on the i next to the “Bike” filter, a popup shows displaying the same stats, but broken down by bike:

Table of rides, distance, elevation and hours per bike
Stats broken down by bike

The riding stats in my year-in-review posts used to include some manual calculations because Strava would not provide the stats by bike type (mtb, gravel, etc.). Now I can simply use the ”Type”, “Date from”, “Date to” filters, and even get a split by bike model. Lovely.

Ride details

When a ride is selected in the list of rides, or its track is clicked on the map, you see the full details:

Details of a 193 km gravel ride: stats, profile graphs, photos, and the track colored by gradient on the map
The details of a ride

Most data come from the recording device: initially, my iPhone running the Strava app; then my Apple Watch directly, via Apple Health workouts. The Apple Watch does not provide elevation loss, but it can easily be derived from the elevation data. It also does not provide power data, whereas Strava gives an estimate. The bike model and ride type is manually added to each ride. The count of lift rides is something I added because neither Strava nor Apple Health workouts are able to detect them and ignore those elevation gains in the ride’s total. More on the topic of lift rides and elevation profiles later.

There are 3 graphs with the riding distance on the x axis that show:

When you hover over the graph, a vertical bar and a dot shows the current position in all 3 graphs, and a traveling dot is shown on the map:

Hovering over the profile graphs moves a dot along the track on the map
Replay the ride by hovering on the profile graphs

Finally, the ride’s pictures and videos are listed in a grid. When clicked, a media is shown in fullscreen, with the caption below it, if there is any.

Mobile friendly

Of course, the app also works on a mobile. I chose to keep all the features, but simply collapse the search/filter section by default on smaller screens. A summary shows what search or filters are currently applied.

The app on a phone in dark theme, filtered on Whistler rides
On mobile, here with the dark theme

Elevation corrections

When I started this little project, I was not expecting the data recorded by the watch to be so bad. It turns out, the barometer can recalibrate mid-ride, or drift, or misinterpret some pressure changes as altitude changes. I noticed it in 2025 and this year, and Strava gives the option to use location-based elevation data. I was not entirely sure how I could approach the problem, so I brainstormed with Claude Code. It suggested to use the free Copernicus GLO-30 global digital elevation model. After it implemented a first version of elevation correction, I asked for some stats of rides that have been corrected, or not. I manually went through many rides and told Claude Code the ones that still have unexpected elevation spikes, which helped to improve the elevation correction algorithm. The rules that follow all make sense I think, but the fact that they are only generalizations of issues found in a bunch of real rides does not make me confident that all cases are covered. And I have to admit that I haven’t given it much thought.

Claude Code’s explanation of its elevation correction code (with some simplifications by me)

Lift ride detection

While I was going through elevation corrections with Claude Code, I got side-tracked with lift detection. There’s something that has always bothered me with Strava: during bikepark days, so when you take lifts/gondolas/funiculars to go up, and mostly only ride down, Strava would count the elevation gain from the lift ride. What I usually ended up doing is pause the recording when getting on the lift, and resuming when at the top. But of course, this was prone to mistakes. I always thought that detecting lift rides should be fairly simple: it’s a straight line, climbing lots of elevation in an impossibly short time, usually over unrideable terrain. Like for the elevation correction, I let Claude Code come up with some heuristic by giving it a list of rides and the expected number of detected lift rides. Here’s Claude Code’s explanation:

Claude Code’s explanation of its lift detection code (with some simplifications by me)

Importing and exporting rides

My plan was to first export all the data from Strava then import it into my app. Once this worked, I would stop using Strava, and import individual rides directly from Apple Health as they were completed. This means that the code dealing with the ride import has to be able to parse 2 different data sources. It reads some data directly from the export (e.g. date/time of ride, media files), it does elevation corrections and lift ride detection, and generates a simplified track displayed when all rides are shown. One feature I copied from Strava is the exclusion of privacy areas at the beginning and end of rides, to make it slightly harder to figure out where I live(d). This also needs to be done during the import, so none of the displayed data contains locations in the privacy area.

If I want to get rid of Strava, I need a way to get the cycling workouts recorded by my Apple Watch to appear in my Rides web app. The workouts are stored in Apple Health. Apple however offers no way to export one or several workouts. It only allows to export all health data (steps, sleep tracking, etc.) at once, which takes forever. Shortcuts does not work either for that use case. The only solution is to create a small companion app for the phone, and give it access to health data (just like Strava). Claude Code obviously helped me with that, because I have no experience in iOS development.

Apple Watch, then Apple Health, then the Workout Exporter iOS app, then the server, then the web app
A ride’s lifecycle

The Workout Exporter app simply:

The server then goes through the aforementioned process to import the ride. It provides a way for the Workout Exporter to display the work in progress, because for example downloading the digital elevation model for the elevation corrections might take time on the server. Any ride can be re-exported any number of times, and will simply overwrite what was previously exported.

iOS app listing cycling workouts
The list of cycling workouts in Apple Health
iOS app showing a workout with title, bike, type and a photo
Images and videos can be added to a ride

Coding with AI

After playing a bit with AI-assisted development at work about a year ago, and not being convinced much, I have to admit that the progress since has been staggering. For this little web app and iOS companion app, the coding agents were spot on most of the time. I used Claude Code with Opus 5.5 with medium effort on a 200$ yearly Pro plan I reluctantly paid for in March, after a coding assignment I did for a job application required AI-assisted coding.

Methodology

I haven’t read anything about agentic coding, and have been a bit more hands-on. That’s probably something to improve on. In any case, my methodology so far has been to ask the AI to set up the project (providing library names, technologies, languages…), and then:

Once I have the basic functionality (a.k.a. an MVP):

When the code looks solid and is well tested:

I have played with describing features with more or less details, and have been surprised with how well Claude Code has been dealing with my poorly specified features.

Features I did not ask for

Many times, Claude Code has been suggesting, or sometimes (annoyingly) straight up implementing, features or details I have not asked for:

Claude Code comparing file sizes for several simplification tolerances
Asking the AI to compare degrees of simplification

Small frontend dev details I learned from AI

I kinda promised myself to read all the code the AI produced. But I miserably failed. The backend first was implemented in Python because that’s what the AI picked, and it was an excuse for me not to look too closely at the code, not knowing the language much. Then I realized that my hosting provider doesn’t offer Python servers, so I made the AI rewrite the whole backend in Typescript on Node. And I still haven’t read it completely. In some of the parts I did read, I lost patience trying to understand the code, and asked the AI to explain it with schemas and examples, which are the 2 embedded explanations you see further up in this page. At first my reason for wanting to read the AI’s code was mistrust, but after seeing the quality, it’s more to learn from it. That’s for the backend/server part. The same goes for the companion app. I have no experience in iOS app development. While the first iteration of the app (listing rides, and generating a zip to download) was fairly straightforward and understandable for a beginner, the final product with API calls and unit tests is quite more complex. For the web app however, I read all the code because I knew I would understand it, and could potentially learn some neat tricks.

So here are some examples of small frontend details I learned from the AI.

  1. I knew about the dialog HTML element, but never implemented a popup from scratch with it. It’s surprisingly straightforward:
<dialog … aria-labelledby="…">
    …
    <form method="dialog"><button … aria-label="Close">×</button></form>
    …
</dialog>
dialog::backdrop {
    background: rgba(0, 0, 0, 0.4);
}
  1. I didn’t know about matchMedia() that is a much better way than user-agent sniffing to have code execute on mobile only:
// the search and filters take too much room on a phone: start with them closed
if (matchMedia("(max-width: 700px)").matches) filterSection.open = false;
  1. It never occurred to me to use fetch to load a local JSON file. I usually just paste the JSON in the HTML. The downside is that you need a local server for it to work (in other words, accessing index.html via file:// won’t work):
fetch("rides.json", { cache: "no-cache" })
  1. Adding invisible but clickable wide lines on the map to make the thin tracks easier to click:
map.addSource("tracks", { type: "geojson", data: tracks });
map.addLayer({
    id: "tracks",
    type: "line",
    source: "tracks",
    …
    paint: { "line-color": TRACK_COLOR, "line-opacity": 0.75, … },
});
…
// invisible wide lines to make thin tracks easy to click
map.addLayer({
    id: "tracks-click-area",
    type: "line",
    source: "tracks",
    paint: { "line-width": 14, "line-opacity": 0 },
});
…
map.on("click", "tracks-click-area", (event) => { … });
  1. I knew about this hack to get rid of diacritical marks (e.g. to transform “enchaîné” into “enchaine”). But the AI chose to use the weird looking /[̀-ͯ]/ regular expression instead of ̀-ͯ:
const normalizeForSearch = (text) => text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
  1. I never thought about this hack of using an attribute to hide elements:
<div id="gradient-legend" class="map-legend" hidden></div>
[hidden] {
    display: none !important;
}
  1. A smart use of selectors to hide the keyboard shortcut hint in the search input:
#search:focus ~ kbd,
#search:not(:placeholder-shown) ~ kbd {
    display: none;
}
  1. Using the accent-color CSS property (not supported on Chrome) to change the color of checkbox inputs.
  2. Using the place-items CSS property as a shorthand for align-items and justify-items.
  3. Using Array.prototype.fill() to create arrays programmatically.

Final thoughts

I must admit that AI-assisted coding has come a long way in the last couple of months. I’m impressed. This whole project is not rocket science, but there were enough unknowns (the iOS app development), unexpected developments (all the elevation corrections due to faulty data) and small details here and there that it would probably have taken me about 10x as much time if I did it on my own. Working with Claude Code feels like being helped by a smarter (seemingly) and way way faster colleague. What I also noticed is that the AI has a tendency to over-engineer features. I’m not sure yet if my approach is good: maybe I’m slicing up the work too much, maybe I’m detailing the specifications too much, maybe I have not detailed the non-functional requirements enough (hence the over-engineering)? I will have to experiment a bit more. I also want to try local LLMs for coding, but my M2 MacBook Air with 24GB of RAM might be a bit weak.

On a higher level, despite being truly impressed by the result in this project, I’m still not convinced that the turn this whole AI thing has taken really was something we needed. Especially considering the resources and staggering amounts of money poured into datacenter construction, the cannibalization of the computer hardware production, the shameless copyright violations perpetrated to train the language models, etc. And all of it being dictated by a handful of companies and their lunatic billionaire CEOs.

Weird times. But at least I can always go for a nice bike ride whenever I feel like it, and then see its track on the map.