My blog workout log
This site isn't dead. I've just spent all my free time the past few months building this thing to track my workouts. In this post, I'll share why and how I made a workout log for my blog.
Background
For most of my life I've been physically active. The years I lost the habit of regular physical activity also happen to be the years I struggled the most psychologically. I don't think that's a coincidence.
Physical and mental health are two sides of the same coin.
In 2016 I began making an effort to get back into the habit. It was hard going at first, but eventually I found my groove. One of the things that helped me get there was collecting workout data. I'm a numbers guy, and I enjoy looking at data, statistics and trends. Even more so when they directly reflect the work I'm putting in and reveal the resulting progress.
To no one's surprise, Strava quickly became my social network of choice. More than that, it became the place I collected and perused the data about my exercise. Individual workouts and aggregated statistics and trends. Even though I used a Garmin device to capture my data, I found their platform to be less than user friendly and, quite frankly, ugly.
Taking control of my data
What eventually became the backbone of the workout log you can now browse through on this site had its genesis in a Google Sheet spreadsheet from four or five years back. I wanted to collect my data in one place. To have the ability to look at it any way I'd like, as opposed to being limited to Strava's various cookie-cutter offerings. And to have some control of my own data.
From Strava to Google. Talk about getting out of the frying pan and into the fire.
Relying on a Zapier integration, I used the Strava API to fetch data about my workouts and write it to a row in a Google Sheet. A simple and straightforward solution.1 But one that needed to change when I [deleted my Google account] earlier this year. Before I deleted my account, I exported the data to a plain CSV file.2 Recreating the various reports was a trivial task with the excellent open source alternative LibreOffice.
It was great to have my full workout history stored locally and in an open format that didn't lock me into any one vendor. There were two issues, however:
- I had no way of automatically updating my workout log with data from whichever device I used to capture it
- My data was only available on my computer and not on the web
The first was not a deal breaker. At the time. Since "retiring" from running (to the extent that a hobby jogger can retire from running) I had gotten into the habit of only using a stop-watch to time my workouts, and logging these by hand in my spreadsheet. Still slightly neurotic when it came to running, I used a footpod to record the distance of my runs.
Point two, however, I knew I had to do something about.
Last year I made a reading log. You can read about the process of putting that together. Armed with the experience from that adventure, I felt like putting together a workout log would be fairly straightforward. And here we are, six months after I embarked on that "fairly straightforward" task, and I spent a good few hours just yesterday on that "fairly straightforward" task.
Let's see what building this thing looked like.
Making a workout log
From a distance, the specifications for my workout log were fairly similar to my reading log:
- I log all my data in a spreadsheet
- The data from that spreadsheet should populate a section of my website
So I feel like I could be forgiven for thinking "How hard can it be when I've already done this other, kinda similar thing?" at the outset. Still, I should have remembered that old expression about the devil and the details.
Data storage
My first challenge was with how to store the data. I began with all my workout data stored in an ODS spreadsheet. I modified the spreadsheet using LibreOffice.
Using the ezodf Python package to work with the limited data types in my reading log was fine. But once I began trying to programmatically add as well as read and parse data, I simply could not find a reliable way to handle dates. No matter what I did with my scripts, manually working with the data in LibreOffice would mess with the data types and screw up my downstream dependencies.
After spending far too many frustrated hours trying to sort it out, I was reminded of xan's love letter to the CSV format:
CSV is text
Like JSON, YAML or XML, CSV is just plain text, that you are free to encode however you like. CSV is not a binary format, can be opened with any text editor and does not require any specialized program to be read. This means, by extension, that it can both be read and edited by humans directly, somehow.
I was gaining newfound appreciation for his sentiments. My reason for wanting to hang on to the ODS format and proper LibreOffice compatibility was because I still had a bunch of pivot tables and reports in the spreadsheet that I used to look at my training. After some back and forth, I made a decision:
This is just where I store the data. The presentation of the data takes place on my website. Needs around reports and aggregation? Solve them on the website.
This decision freed me to transform the data to a bog standard Comma Separated Values (CSV) file. Here's what the first few entries of my workout log data file look like:
Date,Type,Duration,Distance,PaceKm,AvgHR,MaxHR,File,Title,Notes
2017-01-07,Run,0:50:37,10.0,0:05:04,174,191,2017-01-07_09-08-23_gc_1513771713.fit,,
2017-01-12,Run,0:51:45,8.87,0:05:50,155,177,2017-01-12_17-13-06_gc_1522171411.fit,,
2017-01-14,Run,0:35:48,6.99,0:05:07,177,189,2017-01-14_10-40-24_gc_1524448169.fit,,
And the corresponding entries in my workout log are here, here and here.
Working with a bog standard CSV file let me get around all the problems I'd faced with handling dates. Parsing the data with just standard Python packages was a breeze, and I was good to move on to the next step: Figuring out how to present the data on my site.
Presenting the data
My vague and not very-fleshed-out first thought was to just list every workout in a table. I mean why not use a table to present tabular data? Tables were literally made for this!
And so that was the first version of the workout log: One page with one big table with columns for the data points in the CSV file and a row for each workout. That's three thousand and some rows. It wasn't exactly user-friendly. And when the only user I'm developing for is myself, it's hard to let that slide.
I got to work and quickly identified a few improvements:
- Group the data into yearly sections and add document navigation
- Further divide the big tables into monthly sub-sections with separate tables
- Add document navigation between the sections
Everything was still shown on a single page. Nevertheless, these changes instantly made the data more digestible for human users. That is to say me.
At this point I was fairly happy with the setup, and began thinking about some simple ways to "gamify" the data. What can I do here so that when I go and look at my workout data (which, I'm embarrassed to say, I do way too often, as in multiple times per day) I am more likely to get inspired to go and workout today, or tomorrow if I've already done the work today?
Totals
There is nothing more motivating to me than seeing all my effort presented in a visual way. A graphic illustration that is proof of my work, and a bar to measure my effort against previous versions of myself. Keeping my "blue bars" on Strava consistent has gotten me out the door more times than I can count. Sometimes to the detriment of my fitness, no doubt.
The time unit of "week" did not exist in my setup. So I was faced with a choice: Do I further complicate the logic and the setup by introducing a secondary time grouping? Ditching months was not an option. Unlike weeks, months fit cleanly into subsections of years. Plus I like seeing how my training volume changes from month to month. I decided to settle for monthly totals, and went for a super simple look drawing a bar for each month:
Note that the months were reverse chronologically ordered at this point. I've since switched that up, and I will get back to why in the next section.
The bars are drawn relatively to each other based on active time per month within the given year. The chart itself doesn't show totals. But if you're using a pointer based device (as I usually do) you can hover the pointer on a given month and the tooltip will show the total for that month.
The chart also serves as the navigation within a month. Much nicer than the pure text table of content I had used in the first iteration. Clicking on a month name or the bar for that month will take you straight to the header for that month. Here you'll find a quick summary of the month showing active days, active time and distance covered.
Active days
Another aspect I find motivating is collecting active days. My primary motivation for working out is health and wellness, and I believe that being active with your body on most days is a key contributor to achieving that. I wanted a way to track and display how I did in this aspect.
The inspiration came from an unlikely source: The commit heatmap on Github profiles. I've never been an active participant on Github, and yet I've still come across this profile section on many occasions. The first time I saw it, I knew I wanted to create something similar for tracking my workouts from day to day.
It fit neatly into the structure I had created, as a quick overview of active versus inactive days. Similar to the Github heatmap, I played with gradients based on total active time for a day. But in the end, I decided on an either/or approach. Days I log a workout are green, days I don't are red:
Similarly to the monthly total bar chart, hovering the pointer on a given day will show the total active time for that day. Clicking on a (green) day will take you straight to the details of the workout(s) logged that day. In the screenshot, future days are shown as red. This annoyed me more than it should. I later fixed it up by showing future days in a muted grey.
Putting together this section, I had to contend with sort order again: Do I make it reverse chronological? Seeing as my workouts are arranged in that manner, I had landed on the same logic in the monthly bar chart. It rubbed me the wrong way, but I wanted to be consistent. Here, it was an absolute show stopper. Ordering the days from 31-1 looked ridiculous, and I quickly decided that it was out of the question.
Making this decision freed me from the notion that I had to be consistent in using reverse chronological sorting for months as well. So I went back to the monthly totals bar chart and changed it to Jan - Dec there as well. Much better!
Workout details
Presenting the workouts details is the part of this project on which I spent the most time. Setting out with a table-based approach, I boxed myself into that specific solution. It works OK when there is a limited amount of data per entry. Even with a limited number of data points per workout, I felt space was limited. In particular, my notes for each workout will sometimes get a bit verbose, which looked crap in a table based approach.
For a while I still persisted with tables. I applied some styling to the table to make it look a little better:
On wider screens I expanded the width of the table to make it look a bit cleaner when the space was available. At this point I was quite content with how it looked. I even wrote a note declaring the project finished. That didn't quite work out.
Remember how I wrote earlier that logging my activities manually wasn't a problem at the time? Well now it was. By this point I had started running more again (topic for another post) and I was using a watch to track my workouts. Entering data manually suddenly felt like a total chore. So I stopped doing that. Instead I cobbled together a couple of scripts to automate the process.
This got me thinking about all the data from each activity I might want to show in the future. Stuff like splits, elevation plots, a heart rate chart. Perhaps even the GPS track shown on a map. All possible when working with the activity files directly. In that world a table row for each workout won't do.
I knew I didn't want to split each activity into a single page. But how could I make the overall page browsable while still make space for more details for each activity?
Dropdowns, of course.
Previously I had discarded this option. I try to stick to basic HTML and CSS, and I thought dropdowns required Javascript. It was while browsing Jim Nielsen's Blog that I discovered that it could be achieved natively in HTML with the <details>
element (MDM web docs).
It was the solution to all my problems. Now I had a way of listing each workout per month in an orderly fashion and make as much space for any additional information I'd like to add down the road, by way of the dropdown that expands on a click. It even came with a couple of added bonuses before adding any additional data points:
- I could add an icon to indicate the activity without the page becoming too busy
- The individual data points on each activity also got icons to improve readability
- Finally found space to add a back to top of section (monthly overview) navigation element to each activity listing
And if you haven't checked it out yet, here's a screenshot of the current setup:
All of these minor tweaks added up to significant improvement of the user experience of browsing through my workout log. All of it was the result of months of back and forth. I used the log daily to update and track my workouts. Throughout I've tested and discarded numerous possible solutions to the stuff that bothered me. I don't think I could've somehow landed at the "right" solution without all the experiments I've done along the way.
Speaking of things that bothered me, it was at this point I realised that I had to do away with the single page setup. The page was simply too big and unwieldy. For one, it clocked in at several megabytes in size. What's more, the document height would leave you hard pressed to find the location indicator in the scrollbar. Even with solid in-document navigation, this was a step too far.
A front page and distinct pages per year
In terms of coding the logic that generates the workout log (just another build step added to my home-made static site generator) modifying it to split the log into separate pages per year was probably the most challenging part. As a reminder, I have no development background and can only cobble code together with the help of LLMs.
The script had gotten large enough at this point that I struggled with making the LLMs apply the appropriate changes without breaking something else. Large context windows are great and all, but in my experience most models will quickly forget crucial aspects of information well within the context window. This is exacerbated when a script reaches a few hundred lines of code.3
Eventually I finagled the LLMs to provide a working solution. The script now generates a yearly workout log page at the appropriate destination. /logs/workouts/2025/
for the year 2025, and so on. This left me with my (so far) final design challenge: What the heck do I do with the "front page" of the workout log located at /logs/workouts/
?
My first attempt was unbearably boring. I just showed the log for the current year. It worked fine, but it left me with a problem. I might sometimes want to link to a specific workout. With this setup, the permalink of a workout would change once its year is no longer the current year. I could fix this with some server-side rewrite logic. It seemed completely unnecessary, however.
Surely there must be some way to put the front page to better use?
The genesis of the current solution came during one of my visits to Smashrun. I've logged all my runs here for many years. Mostly because I enjoy some of the numbers they are fairly unique in reporting. Stuff like rolling 365 day total and, my favourite, consecutive days run.
This was a stat I knew I wanted to keep track of. Not one to complicate it, I just copied all the section numbers from Smashrun. That is current streak, longest streak and longest break. Once I had this in place, I got all sorts of ideas for what I wanted to display on this page.
Totals was a no-brainer. Per activity totals followed quickly. Although I had imagined displaying these numbers visually, I began with a text-based approach. Once I had the card-based display of the activity totals, complete with activity type icons, I was actually quite happy with the setup.
Data flow
Manually entering data into the CSV file was off the table once I got back to using a watch to track my workouts again. So let's take a look at what the process for capturing, collecting and storing the data looks like now.
Step 1: Record a workout
Originally I was using my old Apple Watch Series 6 to record my workouts. The battery has degraded something fierce lately. To the extent that, even fully charged, the watch would die before I could finish an hour run in the early morning cold.
I contemplated buying a new watch. Luckily I remembered that I had an old Garmin Fenix 5S in a drawer. It too had a poor battery after years of (ab)use. Unlike the Apple Watch, however, replacing it was a breeze. A new battery cost me less than $10 and possibly (hopefully!) added years of life to the device.
While newer Garmin's come with all sorts of metrics and data points, I have no interest in anything beyond the activity data. This device captures that with great precision, while giving me access to basic features like lapping, customised screens and connecting to an external chest strap heart rate monitor.
It even connects to my footpod to display and capture power data. Plus pace and distance if I'm running on the treadmill. In short, it does everything I need from it. And it does it well.
Step 2: Pull the activity file from the watch
Once the workout is recorded, I need to get a hold of the data. Garmin watches store each activity’s data in a single FIT file. The traditional method is to connect your watch to the Garmin Connect app on your phone. When you open the app it will check your watch for new activities and sync the data to Garmin's cloud ecosystem.
You'll then use Garmin Connect to explore the data from the activity. Or do that on another platform, which will fetch activities from your Garmin account through an API.
This works fine for most people. For dorks like me, however, it's not ideal. I don't want to send all my data straight to Garmin's ecosystem. I want to store it on my own devices, and then decide where it goes. As PJ Onori said in Own what's yours:
Now, more than ever, it’s critical to own your data. Really own it. Like, on your hard drive and hosted on your website. Ideally on your own server, but one step at a time.
Exactly. So how do I circumvent Garmin Connect altogether and get the data stored on my own device? I spent a lot of time looking for something like Gadgetbridge (an Android app) for iOS. No luck! The next option was the obvious solution: Just plug it in!
My Fenix 5s is, luckily, mountable as a standard USB storage device.4 With the help of an LLM, making a Python script to check for new activities on the watch was trivial. This script runs in the background and watches for my Garmin watch. After a workout I plug my watch into my Mac and the script copies over all new activity files from the watch to a specified directory on my computer.
Step 3: Parse activity file and write data to CSV file
Garmin devices store activity details in a file format called FIT (Flexible and Interoperable Data Transfer). These files follow a strict format and are not human readable. Luckily, they are fairly widespread and there are many great options out there for working with them.
Once the activity file is on my home network, another script on my home server takes over. This Python script uses fitparse to extract the relevant details from the FIT file and write a new entry to the CSV file that contains my entire workout log. It then uploads the file to the relevant services (I currently send my workouts to Intervals.icu for advanced analysis beyond the scope of my comparatively simple log) before archiving the file on my local server.
Step 4: Add title and note to the activity
The activity is now in place in my workout log. But an important part of tracking your training is adding some notes about how the workout went. Once I decided that this would be the canonical source for all my workout details, I needed a way to add notes to a workout. The current design iteration also lends itself nicely to a workout title in the listing.
To achieve this, I went with the simplest option of adding the title and note to the raw CSV file. Take the activity for when I ran my marathon personal best. It looks as follows in the CSV file:
2020-09-13,Run,2:39:34,42.33,0:03:46,,,2020-09-13_09-00-32_gc_5529362778.fit,Perseløpet Maraton,"Official time 2:39:34. A 3 min PB! Short summary: 30 km comfortable, 6 km struggle and 6 km death march. I wrote a <a href=""https://run161.com/race_reports/perselopet-boston-maraton-2020/"">big race report</a>."
The penultimate data point is the title, and the final is the note. Shockingly simple yet surprisingly versatile, as it gives me the full flexibility of HTML to format my notes.5
Addendum: Compiling a complete FIT file archive
With the workout log set up for being updated based on activity files, I got an itch. The log consists of more than 3000 entries. Yet only a couple dozen can be tied to an actual activity file containing all the data. For everything else, I don't own my data. Just a summary.
The work, I realised, was incomplete.
My first and most obvious challenge was collecting all the files. Over the years I've not been particularly consistent about storing them in one particular place. The only exception was my running, which I've always sent to Smashrun. So I sent them a data export request, hoping to at least collect the source files for all my runs. Unfortunately I never heard back. As I've never paid them a cent, I don't really blame them.
This left me in somewhat of a pickle. After contemplating the issue for a couple of weeks, I found the solution: The iOS app Rungap. I'd used it previously to sync workouts between services, and, on closer examination, it seemed the Swiss army knife I was looking for.
I've always used either a Garmin or an Apple device to record my workouts. Rungap would import all my activities from both Garmin Connect and Apple WorkoutKit. (It only took almost a workday to fetch all my activities from Apple's ecosystem!) Using Rungap, I then exported every single workout file to an old Dropbox account. From there, I downloaded them to my own server.
Success!
Only one more task remained. Rebuilding the workout log based on the activity files and cleaning the duplicates. For this, I created a script with a simple set of rules:
- For each entry with an associated FIT file, check if there exists an activity with the same type on the same date.
- If yes, delete the activity without an associated FIT file.
- Leave all other entries without an associated FIT file.
I could default to this simple logic, because I knew that if I had used my watch to record an activity on a given day, I wouldn't have created a corresponding manual entry. That way I didn't have to go through the trouble of comparing stuff like duration and/or distance with certain tolerances and what not. Simple is better in this regard.
After running the script, I found that my log had actually been missing quite a few activities. A bunch of strength training sessions, some Nordic skiing as well as a few rides and hikes. So not only did I collect all of my data by doing this, I also ended up filling in quite a few missing pieces.
At the time of writing, my workout log comprises 3,129 activities. 2,839, or 91% of these are tied to an activity file stored on my local home server that contains the complete details of the workout. For the remaining 290 activities, these details simply weren't recorded. The information stored in the CSV file is all that exists.
Closing thoughts
I've spent a lot of time working on this. Probably more than I did creating the static site generator that powers this website and which orchestrates the workout log. (Whenever I run my site generator, the workout log is updated based on the CSV file.)
But it was a perfect trifecta of a passion project. It combines my interest for running and working out with making websites and tracking stuff. Working on this project was a lot of fun. I also have a bunch of ideas for how I can improve the workout log and make it suit my needs even better.
Totals per year is a big miss at the moment. I also want to show these totals per activity type. While I ditched it initially, I'm still very much interested in my weekly totals. (Just look at notes for the last activity at the end of any given week.) As a compromise, I might add a weekly bar chart for the last 12 ISO weeks to the front page.
Another point is the unit for the monthly totals bar charts. They are currently based on active time, while (at the moment) I'm more interested in distance covered. As I'm already calculating both values, perhaps I could employ some JavaScript trickery to switch between time and distance?
And then there's the activity details I already mentioned earlier. I'd like to show (much) more details for each activity. To start off, stuff like:
- Detailed pace data (either per lap or a simple line chart)
- Detailed HR data (either time in zone or a simple line chart)
- Elevation plot
- Map based on GPS coordinates
All of this is very much achievable without having to change too much in my current setup. And I'm fairly confident that I have the necessary skills and experience to implement it without too much trouble. (Famous last words!)
But, for now, I'm going to take a break from this project. Instead of spending my free time working on improving the setup, I'll just try to enjoy the platform I've created. Perhaps I'll find that it is fine just the way it is. Or come up with ten other new ideas I want to work on instead of the things I mentioned above.
-
In Google Sheets I put together a couple of dashboards recreating and improving some of my favourite reports from Strava and Garmin Connect. Publishing these straight to the web from Google Sheets was very simple. I then bookmarked them and used them to follow my progress. ↩
-
Deleting my account was anything but simple. That turned out to be because of some Google Script I had created in this very spreadsheet. Figuring that out, and how to fix it took me several days of research. I was elated when I was finally able to delete my account! ↩
-
The advice from this short post (let's be honest, probably AI generated) meshes very well with what I've experienced: "…if you want to make sure the LLM pays attention to everything you need, it is crucial to tell it as much as you can at the beginning of the conversation and then reiterate every important point later in the conversation." When a script becomes too long, however, it is usually full of dependencies and therefore one change is likely to break the entire thing. Splitting logic into several scripts, therefore, is probably good practice when using LLMs for coding. ↩
-
I believe newer Garmin devices are not mountable as USB storage device. Instead, they rely on proprietary protocols and you need to use a program from Garmin or a third party to fetch data, even when they are plugged into your computer. Bummer! ↩
-
It is, however, quite easy to mess up a CSV file. A misplaced comma will do it. It might also make structurally more sense to somehow attach the title and notes to the actual FIT file. To that end, as FIT files don't support titles or comments, I've been thinking about storing this data as a companion text file in the same directory I store the FIT files. The only thing that's stopped me from doing this is that it adds a little bit of extra friction. Instead of just opening "WorkoutLog.csv" and adding my comments after importing a workout, I'd need to open a new file every time. I guess I could add that to the script, somehow. But let's see what I end up doing. ↩