Statistics from my runs, downloaded from Garmin Connect and shown in a web app. Runs in the k3s homelab cluster.
runstats: Blazor Server app (.NET). Holds the UI and the sync, and owns the PostgreSQL database.garmin-gateway: small Python service that talks to Garmin Connect through python-garminconnect. It has no database; it only passes Garmin's responses on to the app.
A Kubernetes CronJob triggers the sync; it can also be started from the UI.
The work is planned in the GitHub issues.
RunStats.slnx
src/RunStats/ the app (Blazor Server)
Components/ app shell: App, routes, layout, error pages
Features/<Screen>/ one folder per screen (Dashboard, Runs, Pace, Heatmap)
wwwroot/js/ the scripts for the heatmap, the charts and the pace calculator (tests: tests/js, Node's test runner)
wwwroot/lib/leaflet/ Leaflet 1.9.4, the map library, kept in the repo (BSD 2-Clause)
Data/ DbContext, entities, migrations, startup and time zone helpers
Garmin/ client for the Garmin gateway
Sync/ the sync with Garmin and its API
Processing/ turns a run's raw data into stats, laps, samples and its track
tests/RunStats.Tests/ unit, component (bUnit) and PostgreSQL tests (xUnit v3)
gateway/ the Garmin gateway (Python, FastAPI), see below
.github/workflows/ci.yml build and test on every pull request and push to main, publish the image from main
.github/dependabot.yml weekly update pull requests for NuGet, uv (gateway) and GitHub Actions
NuGet versions are locked in packages.lock.json (enabled in Directory.Build.props); CI restores in locked mode.
After changing a package, commit the updated lock file too.
The lock file also holds a package the SDK adds implicitly, whose version follows the SDK. So when you change the
SDK version in global.json, regenerate the lock files in the same pull request:
dotnet restore --force-evaluateThe Docker image builds with the newest .NET 10 SDK and runtime (sdk:10.0 and aspnet:10.0) and restores without
locked mode, so it picks up .NET patches on every build without depending on global.json.
dotnet build
dotnet run --project src/RunStats
dotnet test --coverletThe heatmap's JavaScript is tested with Node's built-in test runner (Node 24, no packages to install):
node --test "tests/js/*.test.mjs"Tests run on Microsoft Testing Platform (set in global.json). --coverlet writes an OpenCover report
to TestResults/coverage.opencover.*.xml; the settings are in tests/RunStats.Tests/testconfig.json.
Some tests run against a real PostgreSQL in a container (Testcontainers, image postgres:18), so Docker must be running.
dotnet-ef is a local tool (dotnet tool restore). It needs a connection string to build the app, but doesn't
connect for adding a migration:
$env:ConnectionStrings__Default = "Host=unused;Database=unused"
dotnet ef migrations add <Name> --project src/RunStatsA test fails when the model has changes without a migration.
The app needs the connection string ConnectionStrings:Default. It is not stored in appsettings.json;
the app refuses to start without it.
GarminGateway:BaseUrl is where the app reaches the gateway: http://garmin-gateway:8080/ (the Service name in the
cluster) in appsettings.json, http://localhost:8081/ in appsettings.Development.json.
The heatmap draws the routes on CARTO's dark map ("Dark Matter"), which needs a free key from
carto.com/basemaps/apikey: the setting Heatmap:CartoApiKey (user-secrets
locally, Heatmap__CartoApiKey in the container). Without it, the map uses OpenStreetMap's tiles, darkened with CSS.
The browser loads the tiles itself, so the key is visible in the page, as with every map key.
Leaflet is kept in wwwroot/lib/leaflet rather than loaded from a CDN, so Dependabot doesn't update it; to update,
replace the files with a newer release from cdnjs and check them against cdnjs' SRI hashes.
The dashboard's upcoming races come from a Notion database, through a read-only internal connection that has access to
that database only. Two settings connect it: Notion:Token (the connection's API token) and Notion:DataSourceId (the
database's data source). Use user-secrets locally and Notion__Token and Notion__DataSourceId in the container.
Without them, the sync skips this step and the card stays empty.
The database needs these properties: Race (title), Datum (date), Distance (number, km), Type (select),
Registered (checkbox), City (text), Link (URL) and Target Time (text: 1:45:30, 45:30, or 45 for minutes).
"This month", "this year" and weekly totals use the time zone in the TimeZone setting (an IANA id,
default Europe/Amsterdam in appsettings.json), not the server's. Override it in Docker with -e TimeZone=....
The warnings (#15) compare the last days or weeks with the weeks before. Their thresholds are settings in the
Warnings section (-e Warnings__FastRatio=... in Docker); the app refuses to start with a value out of range. The
defaults, in WarningSettings, were tuned on the history up to 2026-10 to about ten warnings in 14 months:
| Setting | Default | Meaning |
|---|---|---|
FastPaceSecondsPerKm, VeryFastPaceSecondsPerKm |
310, 255 | Faster than 5:10/km is fast, faster than 4:15/km very fast |
FastRatio, FastMinMinutes |
3, 45 | Fast minutes in the last 7 days against the weekly average of the 4 weeks before |
VeryFastRatio, VeryFastMinMinutes |
2, 8 | The same for very fast minutes |
VolumeRatio, VolumeMinMinutes |
1.5, 400 | Running time in the last 4 weeks against the 12 weeks before |
LongRunRatio, LongRunMinMinutes |
1.3, 75 | The longest run of the last 7 days against the longest of the 4 weeks before |
HardRunFastMinutes, HardRunMinutes, HardRunsWithinHours |
20, 75, 72 | Two hard runs (this many fast minutes, or this long) this close together |
FormBandWidthSecondsPerKm, FormBandMinMinutes |
15, 5 | Form is compared in pace bands this wide with this much time on both sides |
StrideLengthIncreasePercent, CadenceDropSpm |
4, 3 | Form change at the same pace, last 2 weeks against the 8 before (outdoor runs) |
BalanceShiftPoints, BalanceMinMinutes |
1.5, 20 | Left/right balance change on strap runs, same periods |
Logs go to stdout as one JSON object per line, for a log collector (e.g. Grafana Alloy → Loki) to pick up.
Locally (Development) they use the plain console format. Each log message has a fixed event id (see src/RunStats/Log.cs).
Traces and metrics are sent over OTLP when OTEL_EXPORTER_OTLP_ENDPOINT is set.
At startup the app applies database migrations. While PostgreSQL is unreachable it tries 15 times, 4 seconds apart, and logs
each failed attempt as a warning. If the host doesn't answer at all, each attempt also waits for the connect timeout (15 s by default).
Errors that waiting won't fix (wrong password, missing database, no permission to connect) stop it at once.
A startup failure is logged as Critical and the process exits with code 1.
Run PostgreSQL in Docker; the app creates the database and its tables on the first start:
docker run -d --name runstats-db -p 5432:5432 -e POSTGRES_USER=runstats -e POSTGRES_PASSWORD=<password> postgres:18The connection string lives in user-secrets (stored in %APPDATA%\Microsoft\UserSecrets\, outside the project):
dotnet user-secrets set --project src/RunStats "ConnectionStrings:Default" "Host=localhost;Database=runstats;Username=runstats;Password=<password>"User-secrets are only loaded when ASPNETCORE_ENVIRONMENT=Development (the default in launchSettings.json).
The cluster's database isn't reachable from the LAN. For a one-off look or fix, forward it to this machine; it stays
reachable on localhost:5433 while the command runs. The second command prints the login's password:
kubectl port-forward -n postgres svc/postgres-rw 5433:5432
kubectl get secret -n postgres postgres-runstats -o jsonpath='{.data.password}' | base64 -d; echoEvery push to main publishes the image to GitHub Container Registry, tagged sha-<short commit sha> and latest,
after the tests pass and a Trivy scan finds no fixable critical vulnerabilities:
docker pull ghcr.io/fdaniel02/runstats:latestOr build the image yourself from the repository root:
docker build -t runstats .The container runs as the non-root app user.
Pass the connection string as an environment variable (__ stands for :), preferably from a file so the password
stays out of your shell history:
docker run -d -p 8080:8080 --env-file runstats.env runstatsrunstats.env contains a single line, without quotes:
ConnectionStrings__Default=Host=<db-host>;Database=runstats;Username=runstats;Password=<password>
Keep that file outside the project folder, so it can't end up in the image or in git.
A sync reads Garmin's whole activity list through the gateway and keeps the runs: type running (id 1) and its sub types
(parent type id 1: treadmill, trail, track, obstacle runs, ...). It compares each run's list entry with the stored one:
- new run: downloads its detail, laps, heart rate zones, weather and FIT file and stores them as they came
(
RunRawData), together with aRunsrow with the basics from the list - changed list entry (e.g. renamed, marked as a race): downloads and stores the run again
- unchanged: nothing is downloaded
- gone from Garmin: deleted, only when the whole list was read
Each run is stored in its own transaction, so a sync that stops halfway (rate limit, failed login, app stopping) keeps the runs it stored, and the next sync continues from there. Downloading a run takes about 6 s, because the gateway spaces its calls to Garmin.
After the downloads, the sync processes the raw data of every run whose ProcessorVersion is below
RunProcessor.Version: the stats in Runs (from Garmin's summary; VO2max from the list entry), RunLaps (from the
splits), RunSamples (the FIT file, decoded by the gateway) and RunTracks (the GPS positions simplified to 3 m, as an
encoded polyline with a bounding box). Raise RunProcessor.Version when the processing changes: the next sync then
processes every stored run again, without downloading anything. A run whose raw data can't be processed gets the reason
in Runs.ProcessingError and isn't tried again until the version changes; if the gateway itself is unreachable, the
sync stops and the next one continues.
Last, the sync updates PersonalRecords from Garmin's records, keeping the running ones (by Garmin's record type: 1 km,
1 mile, 5 km, 10 km, half marathon, marathon, longest run). Records Garmin no longer reports are removed;
UpdatedAtUtc changes only when a record's value, run or time changes. Garmin leaves a record's local time empty, so
AchievedAtLocal is its UTC start shifted by the activity's offset.
It then stores Garmin's latest race predictions in RacePredictions, one row per distance, keyed by the same record
type. A distance Garmin doesn't report keeps its last prediction. Garmin can return up to a year of daily predictions per
request, so no history is kept here.
Finally, it copies the races dated today or later from Notion into UpcomingRaces, removing the ones no longer there. If
Notion can't be read, the sync logs a warning and still succeeds, and the dashboard keeps the last copy. An unreadable
target time is logged, and the race is shown without one.
The dashboard adds a predicted time to each race. A race within 0.5% of 5 km, 10 km, a half or a full marathon shows
Garmin's prediction. Between those distances it's interpolated on log-log axes, and beyond them it's extended from the
nearest prediction with Riegel's formula (exponent 1.06); both are marked ~ as estimates. Trail and obstacle races and
races without a distance get no prediction.
Only one sync runs at a time. Each is recorded in SyncRuns with its progress and result; a sync that was running
when the app stopped is marked as interrupted at the next start.
curl.exe -X POST "http://localhost:5310/api/sync?trigger=manual" # 202, Location: /api/sync/{id}
curl.exe http://localhost:5310/api/sync/1 # status, counts and errortrigger is schedule (default) or manual. A request while a sync runs returns that sync ("started": false).
Telemetry: a Sync span (the gateway's spans join its trace), log events 10–19 and 22–26, and the metrics runstats.syncs
(by trigger and outcome), runstats.sync.duration, runstats.sync.runs (by change: added, updated, deleted, processed, processing_failed) and
runstats.sync.last_success (Unix time of the last successful sync).
RunStats runs in the homelab's k3s cluster; its manifests are in
cluster-gitops (workloads/runstats/). The database is runstats on the
cluster's PostgreSQL, owned by the login runstats, which has no rights elsewhere. Both are created by the cluster
(cluster-gitops, infrastructure/postgres), not by the app; the app only applies its migrations. The cluster archives
the database to object storage continuously, so it can be restored to any point in time within 30 days.
The size metrics (runstats.db.size for the whole database, runstats.db.table.size per table, indexes and long
values included) are read at start and after each sync.
gateway/ is the only part that talks to Garmin Connect. It returns Garmin's responses unchanged; the app decides what
to keep. It needs uv (winget install --id astral-sh.uv -e), which also installs the
Python version from gateway/.python-version if it's missing. Dependencies are locked in gateway/uv.lock.
cd gateway
uv sync
uv run ruff check
uv run ruff format --check
uv run pytest --covThe tests use a fake Garmin client; they never call Garmin.
| Endpoint | Returns |
|---|---|
GET /activities?start=0&limit=100 |
A page of activities, newest first (limit up to 1000) |
GET /activities/{id} |
The full summary of one activity |
GET /activities/{id}/splits |
Laps |
GET /activities/{id}/hr-zones |
Time in heart rate zones |
GET /activities/{id}/weather |
Weather during the activity |
GET /activities/{id}/fit |
The original FIT file (404 for activities imported without one) |
GET /personal-records |
Personal records |
GET /race-predictions |
Garmin's latest finish time predictions for 5 km, 10 km, half marathon and marathon |
POST /fit/samples |
Decodes the FIT file in the request body into one sample per second (see below) |
GET /healthz/live, GET /healthz/ready |
Up / logged in to Garmin |
/fit/samples doesn't call Garmin: the app sends a FIT file it has stored, so runs can be processed again without
downloading them. It uses fitdecode (MIT), not Garmin's FIT SDK, whose
license doesn't allow distributing it in a public image. Each sample has its offset in seconds from the first record,
position in semicircles, distance, speed, altitude, heart rate, cadence in steps per minute (FIT counts strides, with
the fraction in a separate field), step length, ground contact time, vertical oscillation and ratio, power and
temperature; missing values are null. When a second has more than one record, the last one is used. Files over 20 MB
get 413, files that aren't valid FIT get 422.
Errors: 404 when Garmin doesn't know the activity, 502 when a Garmin call fails, and 503 with Retry-After
while the gateway waits after a rate limit or a failed login.
At startup the gateway logs in: with the saved token in GARMIN_TOKEN_DIR if there is one, otherwise with the email
and password (the account has no two-factor authentication). Garmin refreshes the token itself; when that fails, the
gateway logs in again once. The token file holds the Garmin session, so keep it as private as the password.
Calls to Garmin run one at a time, at least GARMIN_MIN_SECONDS_BETWEEN_CALLS apart. After a rate limit (HTTP 429)
the gateway doesn't call Garmin for GARMIN_RATE_LIMIT_COOLDOWN_SECONDS; after a failed login it doesn't try again
for GARMIN_LOGIN_FAILURE_COOLDOWN_SECONDS, because repeated failed logins can get the account locked.
| Setting | Default |
|---|---|
GARMIN_EMAIL, GARMIN_PASSWORD |
none |
GARMIN_TOKEN_DIR |
/data |
GARMIN_MIN_SECONDS_BETWEEN_CALLS |
1 |
GARMIN_RATE_LIMIT_COOLDOWN_SECONDS |
300 |
GARMIN_LOGIN_FAILURE_COOLDOWN_SECONDS |
900 |
HOST |
127.0.0.1 (0.0.0.0 in the Docker image) |
OTEL_EXPORTER_OTLP_ENDPOINT |
none: no traces or metrics |
OTEL_SERVICE_NAME |
garmin-gateway |
PORT |
8080 |
Logs are JSON on stdout with the same field names as the app's; health check requests aren't logged.
Log lines written during a request carry its TraceId and SpanId.
With OTEL_EXPORTER_OTLP_ENDPOINT set (e.g. http://alloy:4318), traces and metrics go to that collector over
OTLP/HTTP. Requests are traced (health checks aren't); a request from the app continues the app's trace. Each call to
Garmin is a span Garmin <operation> with its outcome and the time it waited for the throttle. Spans and metrics hold
operation names, outcomes and timings only: no credentials, tokens, Garmin data or error messages.
| Metric | Attributes |
|---|---|
garmin.calls |
garmin.operation, garmin.outcome (ok, not_found, unauthorized, rate_limited, error) |
garmin.call.duration (s, without the throttle wait) |
same |
garmin.rate_limits |
garmin.operation |
garmin.logins |
garmin.login.method (saved_token, password), garmin.outcome (ok, failed, rate_limited, error) |
Put the credentials in a file outside the repository, e.g. %USERPROFILE%\.runstats\gateway.env:
GARMIN_EMAIL=...
GARMIN_PASSWORD=...
docker build -t runstats-garmin-gateway gateway
docker run -d --name garmin-gateway -p 8081:8080 --read-only `
--env-file $env:USERPROFILE\.runstats\gateway.env -v garmin-gateway-data:/data runstats-garmin-gatewayThe image is published as ghcr.io/fdaniel02/runstats-garmin-gateway together with the app's. It runs as user
10001 and only writes to /data.