Workout log now totally, 100% feature complete
When I last made some updates to the workout log and wrote about it, I said:
The only thing remaining to make it a proper replacement is some more data on the activity level. Heart rate details (cookie diagram for zones or just a line chart), elevation numbers and line chart plus, possibly, a map of some sort. For the last one, I'm not sure, because I'm loath to introduce third party content on the site. And building a map engine isn't exactly on my list of things I want to tackle.
But I'll keep thinking about that. Either way, I think I want to make some changes to how I'm handling workout data upstream before I tackle any of those. To make the solution a bit more robust.
Lo and behold, in ToDo-WindrunnerSSG.md, my to do list for my static site generator, were the following entries:
( )Workout log improvements:( )Add splits bar chart (ala Strava where bar width = duration, bar height = pace)( )Heart rate graph( )Elevation profile( )GPS coordinates route to SVG( )Refactor title and notes to support unified workout data storage
The workout log has been the defining project of 2025 for me. With the end of the year in sight, I was motivated to take a stab at solving these last remaining features. And now I have. The most recent update in the version log for my static site generator:1
0.2.5
22.12.2025
- Updated
workoutlog_processingto include more data per activity. This includes splits, route, heart rate plot and elevation plot illustrations. Code was also refactored to adjust for upstream changes in workout data storage. The script is now pulling data from individual text files per activity. Minor changes togenerate_feeds.pyandwindrunner.pyto account for logic changes.- No remaining items in
ToDo-WindrunnerSGG.md. Is this thing feature complete?
True to form, I'll spend this post going through my thought process when attempting to solve each of these. The post will be long, boring and only suitable the particularly peculiar persons who wants to know how I've "vibe coded"2 my way to a fully functional Strava replacement as a component of my own static site generator.
Reworking the data architecture
If you read my initial post about the workout log you probably remember (lol) that my data flow was as follows:
- Connect my Garmin watch to my laptop.
- Script extracts new workout files to my laptop.
- Another script parses the workout file(s) and writes the details of the workout to a CSV file.
- I add a workout title and notes to the new workout by modifying the CSV file.
- My site generator creates the workout log based on the data in this CSV file.
This worked OK. But it had two glaring issues:
- Manually editing CSV files is cumbersome.
- Workout titles and notes, as well as manually recorded workouts, only exists in the CSV file.
I routinely messed up the CSV file and caused errors by being sloppy with commas and escaping. Adding even more data for visualisations would require changing the setup. The second point was also nagging me. My workout data was not unified in one place. The .fit activity files were stored in one place, while my notes on the workouts existed only in the CSV file. Manually added workouts existed only as a row in the CSV file.
As directly enriching the activity files is not possible, my idea was to create companion files for each activity file. These files would be identically named text files (only with a differing extension, .md in this case) and they would contain key details of the workout as well as my notes on each workout.
This solved both issues. Editing a single text file with dedicated lines to the title and notes is easier than fiddling with a CSV file. Likewise, it centralises all of my workout data in a single location: The directory where my activity files are stored. Each workout is now merely a text file with a few data points, and — if I happened to be wearing a device to capture more detailed data during the workout — there will be an identically named .fit alongside it containing all additional data like a GPS track, heart rate stream and more.
Next, I had to figure out how to approach generating the workout log based on this new data structure. My initial idea was to keep the CSV as the source for generating the workout log. Intuitively, this seems significantly more efficient than accessing thousands of individual files each time I generate the site. Some testing confirmed that to be true. Generating the workout log based on data in a single CSV file was around an order of magnitude faster than fetching the same data from 3000+ individual text files.
While that may seem like a lot, in practice we're talking about going from 100-200 milliseconds to 1-2 seconds. It was a cost I was willing to swallow. Because introducing a middle layer meant increasing the complexity of the setup significantly. This because there is more often than not a time delay from when the workout file is created and I get around to adding a title and notes. So when do I generate the CSV file to ensure that it is current? My site is rarely generated more than a couple of times per day, either way. The solution can only be "every time a workout file is updated" for there to be any significant resource savings.
Not worth the added complexity.
That said, xan and his followers need not worry. There is still a CSV file. I recently created a workouts feed for anyone who wants to keep up with my workouts.3 Like Strava, only built on an open protocol! To avoid having to access thousands of files twice every time I generate my site, the script creates a CSV index while accessing the files on the first go. This index is then used when generating the workouts feed later in the build process.
(X)Refactor title and notes to support unified workout data storage
Route visualisation
Routes have been on my mind since I first began working on the workout log. The context provided by a visual illustration of where you've run (or biked, skied, skated or anything else) simply cannot be replaced.
But there were some immediate stumbling blocks. Maps, for one. I do not want to introduce third party components on my site, even if there are great options out there. Self-hosting tiles seemed like too much hassle. Plus, there's this privacy thing. While I've been privileged to never really have to worry too much about that since, well, forever, openly sharing my exact geo-location every single day seems a step too far.
Because of this, I left routes alone. When I, thanks to Josh Comeau's excellent friendly introduction to SVG began to wrap my head around SVGs, the solution seemed obvious: Generate an SVG showing the path. This way I can provide some context of what the run was like by showing the route without revealing everything. And I must say, I'm really happy with the result.
Everyone who's run the Berlin Marathon will immediately recognise the route and probably get an emotional response when seeing it in the activity from when I ran it back in 2019:

Similarly, here's a point to point example when I ran Ecotrail Oslo the same year:

I think it provides a lot of context for the activity. Even if it doesn't reveal the exact location, it conveys something about what the run was like that is difficult to get across with words alone. Especially paired with the next point.
(X)GPS coordinates route to SVG
Elevation profile
You can describe an activity with numbers such as metres climbed and descended. But, like with routes, an illustration can give an intuitive understanding of what the activity was like that is difficult to replicate with words. To add a simple chart to show the elevation profile of my activities, therefore, seemed obvious.
After sorting out the route illustration, I decided to use the same approach for the elevation profile: A simple SVG. It is responsive and works fine. Let's look at what this looks like for the same activities mentioned above. First, the Berlin Marathon:

One flaw in this implementation is that the elevation changes are all relative. Meaning that the elevation profile for the pancake flat Berlin Marathon (total metres climbed is 74 metres) looks quite hilly. On the other hand, the Ecotrail Oslo 50k looks like a straight drop by comparison:

Even if Ecotrail has a significant net drop, the total metres climbed is 896 metres and more than ten times as much as Berlin. You wouldn't know just looking at these two charts.
My first iteration was just the lines chart. To add a little more context to the chart, I decided to add labels for the highest and lowest points. Although this doesn't quite solve the relativity issue, it helps mitigate it. And I prefer it to a "fixed height" approach, because the most important point is to get a feel for the relative changes within a particular activity rather than comparing activities.
(X)Elevation profile
Heart rate chart
For the heart rate chart, I reused the exact same approach as for the elevation profile. And it works well. Just have a look at the chart from this 8 x 1000m workout from earlier this year:

Although not a tool for detailed analysis of a workout, the chart illustrates how the heart rate fluctuates throughout the workout. It goes up throughout the reps, dipping sharply during the standing rests. With the label for the highest value, we can see that it peaks during the last rep at 177 beats per minute.
(X)Heart rate graph
Splits bar chart
The last thing I wanted to implement was a bar chart illustrating the pace and duration of the workout splits. Splits, or laps, are segments of a workout. For a regular jog, I use auto split per kilometre. If I'm doing a structured workout, I will manually split at the beginning and end of each repetition.
Looking at these splits is my preferred method of quickly assessing a workout afterwards. For many years, I paid for a Strava premium subscription because I liked their bar charts better than anything else I had found. Suffice it to say, I wanted to get these right.
Conceptually, the bar chart is quite simple:
- Each bar represent one split/lap.
- The height of the bar represents the pace for that split. The taller the bar, the faster the split.
- Split duration is represented by the width of the bar. A wider bar means a longer lasting split.
My first implementation used a relative approach. The fastest split was set at the max height, the slowest at the minimum height, and everything else was given height relative to these two outer points. It worked… OK.
The first thing that bothered me was that for a workout where everything was fairly evenly paced, it would look like pace varied significantly. I could live with that. But, as soon as I looked at a structured workout, I knew I had to improve the approach. Just look at this:

What's interesting here is the variance between the tall bars, which represents the 1000 metre repetitions. Because the rest intervals (walking rest) are so slow by comparison, there is absolutely no granularity between the reps. I am instead wasting space illustrating the meaningless differences between the walking rests. This won't do!
I need a way to scale these bars that will preserve the granularity at the faster end at the expense of the slower end. Bonus points if it solves the issue of exaggerated differences during a run with fairly even splits. To someone smarter than me (low bar!) the solution is likely obvious. My first thought, however, was introducing some kind of fixed pace range. Where 3:00 min/km or faster is set to max height and 7:00 min/km or slower is set to minimum height.
This would work… OK. For running. But if I go for a bike ride, it wouldn't because the paces won't fit that range. A more flexible approach would be better. After consulting with my friendly neighbourhood Claude, the light bulb went on. I can maintain a "relative" approach by anchoring the scale to the fastest lap split pace and capping the slow end based on some factor. In other words, anything X percent slower than the fastest split gets drawn as the lowest bar. For everything in between, I use the available canvas.
In theory, this should solve both issues — as long as I can find the correct multiplier.
After trying various multipliers on for size, I settled on 2. That is, anything that's half the pace of the fastest split gets suppressed at the lowest height. It is a fair compromise between detail at the pointy end, and lack of detail at the slow side. Going back to the same workout we saw above, here's what it looks like with the new approach:

There's clear separation between the repetitions, while maintaining a visual distinction between the warm up and cool down splits. A more aggressive cap, like 1.5, resulted in even more granularity between the fast repetitions, but at the cost of any distinction between the warm up and cool down splits. This compromise works.
Looking at a recent run, it also solves the problem of fairly even splits coming across as wildly different:

Paces here range from 5:50 min/km to 5:36 min/km. With the old approach, the bars would've given the (completely erroneous) impression of a aggressive progression run.
I can now confidently proclaim that this bar chart illustration of workout splits is equal to that which I used to Strava for the privilege of using. It is also fully responsive and works on mobile.
(X)Add splits bar chart (ala Strava where bar width = duration, bar height = pace)
Is this thing complete?
As I wrote in the changelog, there are now exactly zero remaining items on my to do list. Not just for the workout log, but for the entire static site generator that powers this website. A year after finishing the first iteration, I have implemented everything that I had the idea of doing "sometime" when I first began working on this thing. That probably warrants a post of its own. As does the question of whether or not it is complete.
What I can declare with absolute certainty, however, is that the workout log is finished. It does exactly what I want it to do. Nothing more, nothing less. I have no desire for new features or additional elements. If I have to stick to one way of tracking and analysing my workouts for the rest of my life, I am perfectly content with this being it.
That's a nice feeling. And a good note on which to end a year.
-
Yes, like a proper wannabe developer sans actual developer skills, of course I keep a changelog for this thing. And the version numbering is, naturally, completely made up. Whenever I make a change I just ask myself "what do I want to call this version?" before landing on a number. It's pretty cool. ↩
-
Back when I began using LLMs to put together a static site generator that matched my mental model of how these things should work, the term "vibe coding" didn't exist. But now it does, and I suppose it fits what I'm doing here. ↩
-
Not so much because I think that anyone would want to subscribe to my workouts in their feed reader, but, rather, because I have a dream of lots and lots of people tracking their workouts in a way that lets me follow their workouts in my feed reader. Because I get a lot of motivation from seeing other people getting out there and doing the work, and I really miss that aspect of Strava. Be the change you want to see and all that. ↩