A private-first archive tool for turning a Garmin Connect account into a local, reusable activity dataset. It pulls activity summaries and detail payloads into JSON files so future analysis, dashboards, and visualizations can work from your own disk instead of repeatedly touching the Garmin service.
After completing the setup below, export all activities from 2017 through 2026:
export GARMIN_EMAIL='garmin-user@example.com'
./exportGarminYears.sh --start-year 2026 --end-year 2017Enter your Garmin password and MFA code when prompted. The export is resumable, so the same command can be run again after an interruption.
Open all exported years in the map:
source .venv/bin/activate
python -m activity_map data/garminAuthor: Marcel Petrick mail@marcelpetrick.it
Note: projected is generated with AI.
License: GPLv3 or later. See LICENSE.
- Version:
0.0.73 - Runtime: Python 3.14 (the version used for development, the local pipeline, and CI)
This project is distributed under the GNU General Public License v3.0. You may use, study, modify, and redistribute it under the terms of GPLv3. The full license text is in LICENSE, and the package metadata declares the SPDX expression GPL-3.0-or-later, so the license ships inside the built wheel and source distribution.
python -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txtSet GARMIN_EMAIL in your shell or in ignored local .env. The password is always entered manually at runtime and is not read from files or environment variables.
read -r GARMIN_EMAIL
export GARMIN_EMAILpython -m garmin_exportBy default, exported data is written under data/garmin/activities/, which is ignored by git. Authentication tokens are stored outside the repository by the garminconnect package unless GARMIN_TOKENSTORE is set. Do not point GARMIN_TOKENSTORE at a tracked repository path.
Useful options:
python -m garmin_export --output-dir data/garmin/activities --page-size 100
python -m garmin_export --no-details
python -m garmin_export --activity-type running
python -m garmin_export --start-date 2026-05-13 --end-date 2026-06-13The exporter is intentionally conservative for detailed activity downloads:
- Existing activity JSON files are skipped by default so interrupted exports can resume without repeating calls.
- All Garmin requests are paced at one request per second by default; configure this with
--request-interval. - Detail downloads can add an extra
--detail-delayplus random--detail-jitter. - HTTP 403, 429, 5xx, timeout, and network failures use bounded exponential backoff controlled by
--max-retries,--backoff-initial, and--backoff-max. export-state.jsonis atomically updated with completed, pending, failed, retry, and estimated-completion data. Failed activities remain absent and are retried on the next run.
Every run reports what it is doing without needing --verbose:
Collecting Garmin activity list for 2025-01-01 to 2025-12-31 ...
Export plan for 2025-01-01 to 2025-12-31
Output directory : data/garmin/activities-2025
Activities listed : 312 from 2025-01-02 to 2025-12-30
Already on disk : 300 (skipped)
Still to download : 12
Estimated runtime : 1m 24s
12/312 activities processed; 10 downloaded, 300 already present, 0 failed; estimated completion 2026-08-14T18:22:41+00:00
Export finished for data/garmin/activities-2025 in 1m 31s
Downloaded : 12
Already present : 300
Failed : 0 (retried 0 times)
Manifest entries : 312
The activity list is always re-queried, because that is how new activities are
detected, but only activities whose activities/<activity-id>.json file is
missing are downloaded. A rerun of the same command therefore fills gaps -
newly recorded activities and activities that failed earlier - instead of
downloading the archive again. --no-skip-existing opts out and re-downloads
everything. Add --verbose for a timestamped line per Garmin request, retry,
and file write.
A detailed run also completes activities that were previously exported with
--no-details: a stored file that contains only the summary payload is
reported as Summary only in the plan and its activity and details payloads
are fetched, so switching from a summary-only export to a full export fills the
missing detail data instead of leaving those activities incomplete forever. A
summary-only run leaves such files untouched. Detection reads the head of each
stored file and falls back to parsing it completely when that is inconclusive,
so a file written with a different key order is recognised as complete instead
of being downloaded again.
For a cautious 2026 export:
python -m garmin_export \
--start-date 2026-01-01 \
--end-date 2026-12-31 \
--output-dir data/garmin/activities-2026 \
--detail-delay 5 \
--detail-jitter 5To export full years from the current calendar year back through 2017 into separate ignored folders, enter the Garmin password once at startup and run:
./exportGarminYears.shThe script writes to data/garmin/activities-YYYY/ folders, uses
--detail-delay 2, --detail-jitter 2, and --verbose, and accepts extra
exporter flags at the end. For example, ./exportGarminYears.sh --no-details
exports summaries only.
The first year is the current calendar year, so the running year's activities
are always included; --start-year and --end-year override the range. If the
map is missing recent activities, check that a folder for the current year
exists under data/garmin/ and that its export completed - an interrupted run
leaves export-state.json with a non-zero pending count and writes no
manifest.json.
Before the first Garmin request the script prints the repository path, the interpreter it activated, the output root, the pacing flags, the extra flags it received, and a reminder that existing activity files are skipped. Each year then prints its own plan and completion block, and the run ends with a per-year summary of new, already present, and failed activities including the detected date range per year.
Year export layout:
data/
garmin/
activities-2025/
manifest.json
activities/
123456789.json
activities-2024/
manifest.json
activities/
987654321.json
The unique activity file key is Garmin's activity id from activityId,
activity_id, or id. Existing activities/<activity-id>.json files are
skipped by default, so interrupted year exports can be rerun without
overwriting already downloaded activity payloads. Each year-level
manifest.json is regenerated to summarize the latest run. Activity and
manifest JSON files are written through a temporary file and atomically moved
into place, which avoids keeping partial files after an interrupted write.
Date-based exports are split into calendar-month Garmin queries and then
deduplicated by activity id, which avoids relying on a single full-year query
that may be capped by Garmin.
python -m activity_map data/garmin/activitiesThe desktop app loads Garmin JSON exports from an ignored local directory and renders activity tracks over an OpenStreetMap base layer. Downloaded map tiles are cached under the platform cache directory (~/.cache/GarminActivityMap/map_tiles/osm by default, or $XDG_CACHE_HOME); repeat views use the local cache, and panning or zooming automatically requests newly visible tiles. Set ACTIVITY_MAP_TILE_CACHE_DIR to relocate that cache. Following the OpenStreetMap tile usage policy, tiles are fetched by at most two workers, with a burst of 24 downloads allowed so a fresh view fills immediately and a sustained ceiling of five downloads per second across all workers afterwards; tiles already in the cache are served without any delay. The location no longer depends on the working directory the app was started from, so tiles cannot land in an unrelated project folder.
Expected local layout:
data/
garmin/
activities/
manifest.json
activities/
activity-123456789.json
activity-987654321.json
Controls:
- Open Directory: choose a folder containing exported Garmin JSON files.
- Reset View: fit the visible map back to the loaded tracks.
- Track Color: choose one shared color for all rendered activity tracks.
- Track Opacity: make individual routes lighter or stronger.
- Show track names: draw each Garmin activity name near its rendered track.
- Map Opacity: make the OpenStreetMap base layer subtle or prominent.
- OpenStreetMap layer: toggle the map base layer while keeping tracks visible.
- Drag the map to pan, use the mouse wheel to zoom deeply around the cursor, and double-click the map to reset.
- While a directory is still loading, the map keeps fitting each newly arriving batch of tracks until you pan or zoom. After that the view stays where you put it, and Reset View or a double-click hands control back to automatic fitting.
- The bottom-right scale shows one rounded 1/2/5-style distance in kilometers for the current map latitude and zoom.
The app persists the last loaded directory, last run timestamp, track color,
track-name visibility, track/map opacity, map-layer state, and future preference
fields in ~/.config/GarminActivityMap/settings.json. Missing or corrupt files
fall back to safe defaults. Set ACTIVITY_MAP_SETTINGS_PATH to use a different
location.
Map colors:
The selected track color is used for all activity tracks.
Supported Garmin export shapes include activity detail files with geoPolylineDTO.polyline, activityDetailMetrics coordinate metrics, and coordinate-like nested records. Files without usable coordinates are skipped and summarized in the app instead of stopping the load.
When timestamps are available, the loader validates their ordering and computes
geodesic segment speeds. Segments above 30 km/h are flagged and disconnected
from rendered geometry to suppress GPS spikes; the source JSON is never changed.
Use load_directory(path, max_speed_kmh=...) to configure the threshold.
Loaded tracks retain timestamps and altitude where available, plus per-segment distance and speed, total distance, duration, and geographic bounds. Rendering uses cached markers at broad zoom, simplified polylines at intermediate zoom, and full validated geometry when zoomed in.
Parsed tracks and prepared geometry are cached under the platform cache
directory for faster repeat startup. Cache entries are keyed by the resolved
dataset path, the geometry parameters that produced the snapshot (level-of-detail
tolerances, simplification tolerance, segment-split distance, minimum rendered
points, and the speed threshold), plus every activity file's relative path, size,
and modification time, and are written atomically with user-only permissions.
Changing any of those parameters produces a new cache entry instead of silently
reusing geometry prepared by an older build, and superseded entries for the same
dataset are removed. Set
ACTIVITY_MAP_PREPARED_CACHE_DIR to relocate this cache or
ACTIVITY_MAP_DISABLE_PREPARED_CACHE=1 to disable it.
For a headless smoke check:
QT_QPA_PLATFORM=offscreen python -m activity_map --smoke-testTroubleshooting:
- If the map opens but no tracks appear, check the warning count in the left rail. The selected files may not contain GPS coordinates.
- If the GUI cannot start on a server or CI machine, use the offscreen smoke command above.
- To force the synthetic offline background for deterministic checks, run with
ACTIVITY_MAP_DISABLE_TILES=1. - Keep real activity directories under ignored paths such as
data/orexports/; the repository uses synthetic fixtures for tests.
./localPipeline.shThe pipeline creates or reuses .venv, installs dependencies, checks formatting,
linting, strict typing, dead code, complexity, installed dependencies, package
architecture, documentation, package builds, unit tests, coverage, and CLI/GUI
smoke runs. It also loads, prepares, indexes, and renders 1,000 synthetic tracks
with 300 points each, failing if the load-to-first-display time exceeds eight
seconds. Coverage must remain at or above 95%. The pipeline finishes with an
aligned per-gate summary that shows PASS or FAIL plus a one-line detail for each
gate (file counts, test totals, coverage percentage, built artifacts) and exits
non-zero if any gate fails.
The GUI is also covered by offscreen end-to-end tests. These tests launch the real main window with fabricated Garmin-shaped activity files, drive the directory selection workflow, verify incremental loading progress, exercise map pan/zoom interaction, and check recovery after malformed synthetic input. To run the focused GUI coverage check:
source .venv/bin/activate
QT_QPA_PLATFORM=offscreen ACTIVITY_MAP_DISABLE_TILES=1 \
python -m pytest tests/test_gui_e2e.py tests/test_widgets.py \
--cov=activity_map.widgets --cov-report=term-missing --cov-fail-under=0Current result for that focused check: 10 passed; activity_map/widgets.py
reports 95% coverage. The total shown by that scoped command is lower because
the project-wide coverage configuration still includes non-UI modules.
.github/workflows/local-pipeline.yml runs the same ./localPipeline.sh on
GitHub Actions for pushes to master, main, and mpe/**, for pull requests,
and on manual dispatch. The job runs on Python 3.14, the same version used for
local development, installs the Qt runtime libraries needed for
offscreen PyQt6, exports QT_QPA_PLATFORM=offscreen and
ACTIVITY_MAP_DISABLE_TILES=1 so no OpenStreetMap tiles are requested from CI,
and uploads the built packages and the generated documentation as artifacts.
The Local Pipeline badge at the top of this file reflects that workflow on
master. Because CI runs the identical script, a green badge means the same
formatting, lint, typing, dead-code, complexity, architecture, docs, package
build, test, coverage, performance, and smoke gates that run locally passed. The
badge stays grey until the workflow has completed a run on master, and GitHub
caches badge images for a short while, so it can lag a minute behind a finished
run.
Before a major automated operation, create a verified checkpoint and confirm the worktree is clean:
./localPipeline.sh
git commit
./scripts/agentPreflight.shThe detailed incremental-change, rollback, and traceability rules are in
documents/AGENTS.md.
The C4-style architecture views live in documents/architecture.md. Build and validate the local documentation bundle with:
python scripts/build_docs.pyGenerated documentation output is written to ignored build/docs/.
The detailed map runtime flow and the 2026-06-23 performance review are in
documents/data_flow.md and documents/speed_improvements20260623.md.
Reproduce the synthetic 1,000-track rendering benchmark with:
QT_QPA_PLATFORM=offscreen ACTIVITY_MAP_DISABLE_TILES=1 \
python benchmarks/benchmark_map_render.py --tracks 1000Measure synthetic file loading through the first offscreen display with:
QT_QPA_PLATFORM=offscreen ACTIVITY_MAP_DISABLE_TILES=1 \
python benchmarks/benchmark_loading.py \
--tracks 1000 \
--points-per-track 300 \
--samples 3Use --loader-workers and --prepare-workers to compare concurrency settings,
--use-prepared-cache to report repeat-snapshot loading, or
--max-load-to-display-ms to turn the cold measurement into a regression gate.
Garmin activity exports can contain names, locations, timestamps, device IDs, and route data. Keep generated files under ignored paths such as data/ or exports/, and check git status --short before committing.
