← Back to Writing

How I Built Garmin Data Into SpecSelf, My Journal

About this artifact

essayon-the-recordmaintained

method · since 2026

SpecSelf, the journaling app I built, now has a Garmin context panel beside each day's check-in. Open it and it shows that date's sleep, Garmin daily stress, steps and workouts, along with whether the day is complete and when SpecSelf last pulled from Garmin. Behind the panel is Garmin's official Personal API, the option I said was missing when I wrote the first integration post.

That post had one reader for the data, my marathon coach, and its case for automation was shelf life: last night's recovery data informs today's training decision. A journal reads the same watch on a longer clock. I open an entry weeks later, and the numbers beside it still have to describe that day and admit what they do not know. That requirement decided where SpecSelf puts dates, gaps and late syncs.

A small adapter on Garmin's Personal API#

The first post's section "Designing for an API Garmin does not offer" described two community libraries speaking to the same web endpoints as the Garmin Connect site, with a saved login session reused between polls. Garmin now offers the Connect Personal API for an individual's access to their own data, and its FAQ says it is not intended for commercial applications serving many users. SpecSelf's Garmin context reads one account, mine.

Authentication starts from a personal key I generate in Garmin Connect on the web. The key is exchanged for an access token that lasts up to 24 hours, and there is no refresh token, so each new token comes from the key again. The key itself expires on a schedule chosen when it is created. SpecSelf's adapter is a small module on Python's standard library. It reads the key from a secrets store, holds the token only in memory for that run, talks only to one fixed official Garmin host, and caps its requests, run time and response size. No login session is saved between runs.

Delivery is still pull, as Garmin's program overview lists for the personal tier. The feed can only include data the watch has already synced. The first post answered that delay with a rolling lookback window, and SpecSelf keeps the idea: each night a Garmin-only job re-reads the last seven local dates, including the partial current day, so a late sync lands on a later run. Dates older than that window are not re-read automatically.

The coach's move to the same API is implemented and tested locally but not yet released, so in production it still uses the community-library connection.

A journal day is a local date#

SpecSelf collects four Garmin measurements: sleep duration, Garmin daily stress, steps, and workouts by activity type with their count and duration. The first post's rule against turning a failed metric into a zero becomes an explicit state here. Every metric carries its value and unit plus an availability of observed, missing, failed or unsupported, so a rest day with zero workouts reads differently from a day the request failed.

Dates need the same care. Sleep is assigned to the local date it ends. The collector checks that times and timezones line up. An uncertain timezone is marked unknown, and the current day or any unaligned or incomplete day is marked partial. Without those labels, a measurement from the wrong side of midnight could sit beside an entry about a different day. Garmin daily stress also stays apart from the stress field I fill in myself; they are separate fields and neither fills the other.

A changed day gets a new snapshot#

Each nightly run reduces Garmin's responses to normalized daily records and stores them as an immutable snapshot in object storage, identified by a hash of its contents. The same observations produce the same snapshot identity. If a late sync changes a day, the changed observations get a new identity, and conditional creation keeps a write from overwriting an earlier snapshot. Each attempt also writes its own receipt with its status and the snapshot it produced, so a failed run leaves a record without replacing usable observations with empty ones.

The reader behind the panel picks the latest usable snapshot, checks its hash, date and source, and tracks the latest attempt separately. If last night's sync failed, the panel can still show the previous observations, labeled stale, with the time of the last success and the failure state.

That panel is optional and collapsed on Today and on saved entries, and it loads only when I open it. Its stale, partial and timezone labels sit beside the values, with the normalized source details one click further down. Saving a check-in does not wait on Garmin: if the context fails to load, the entry still saves. SpecSelf also never writes or rewrites journal text from the watch, so the measurements sit next to my account of the day without becoming part of it.

A weekly review keeps its sources#

Weekly observations draw on two saved inputs, journal entries and Garmin snapshots. Creating a review pins the exact entry revisions and snapshots it used, and its source links reopen those retained versions later, so an edited entry or a newer snapshot does not change what an old review was built from. The review reports how many days were observed, missing, stale or partial.

Its generated observation sections come from fixed templates that count days and report measurements. Those sections carry no free-form model conclusions and no correlations. A week's sleep appears as measurements beside the count of days actually observed, and what that week was like stays in the entries I wrote.