Skip to content

Add heart rate to a workout file: merge strap HR into an HR-less GPX for re-upload (#2679 option 1) - #2817

Closed
andre-fu wants to merge 1 commit into
ryanbr:mainfrom
andre-fu:strava-hr-merge
Closed

andre-fu wants to merge 1 commit into
ryanbr:mainfrom
andre-fu:strava-hr-merge

Conversation

@andre-fu

Copy link
Copy Markdown

What this adds

A run or ride recorded in another app without an HR sensor exports as a GPX carrying position and times but no heart rate. This adds a Data Sources card — Add heart rate to a workout file — that takes such a file, splices the strap's stored per-second HR into each timed trackpoint as Garmin TrackPointExtension (gpxtpx:hr), and hands the merged file straight back out through the share sheet / save panel. Uploading it to the originating service (Strava included) shows the route and heart rate / zones there.

This is the #2679 option-1 direction: automating the capability that already exists, with no third-party credential, no network, and no scope change. Refs #2679.

How it works

  • GpxHrInjector (Packages/StrandImport, pure, Linux-safe): deliberate TEXT SURGERY on the original document, not a parse-and-reserialize — position, elevation, times and everything else survive byte-for-byte; the only changes are the inserted extension blocks and (when missing) the xmlns:gpxtpx declaration on the root tag. HR matches by nearest stored sample within 5 s (ties take the earlier sample).
  • Read-only on the store: the file's own time window selects repo.hrSamples(from:to:) — the existing union/dedup funnel — and nothing is imported or persisted.
  • Every refusal is loud and carries its numbers (not GPX / no timestamps / already has HR / nothing matched with the window and counts / too many points). A silent unchanged re-export is the one outcome the flow cannot produce.

Cross-platform parity

  • GpxHrInject (android com.noop.ingest) is the byte-identical Kotlin twin, pinned by oracle per AGENTS.md: the Swift implementation's stdout over shared fixtures is pasted verbatim as the Kotlin test's expected literals (GpxHrInjectTest), covering the Strava-shaped file, the existing-<extensions> case, and the tolerance boundary, plus the same refusal cases.
  • Deliberately not in this PR: the Android Compose entry point. The byte-contract layer ships on both platforms together here; the Android card + string resources are a mechanical follow-up and are called out rather than bundled (UI parity is feature-level, and keeping this PR to the contract layer + one platform's UI keeps it reviewable).

Verification

  • Packages/StrandImport: full suite passes locally (263 tests incl. the 14 new — round-trip through ActivityFileImporter, byte-preservation by stripping the inserted blocks, tolerance/tie rules, every refusal).
  • Android: GpxHrInjectTest (oracle + behaviour pins) passes under testFullDebugUnitTest.
  • macOS app target builds (xcodebuild -scheme Strand … build) and StrandTests run locally; iOS compiles via this PR's app-build.yml run (local Xcode here has no iOS 26 SDK).
  • Tools/i18n_audit.py --ci main green: the 12 new catalog keys are translated for all nine shipped locales.
  • Tools/doc_comment_lint.py green.
  • No BLE, migration, or scoring path is touched.

🤖 Generated with Claude Code

A run or ride recorded in another app without an HR sensor exports as a
GPX with position and times but no heart rate. The new Data Sources card
takes that file, splices the strap's stored per-second HR into each timed
trackpoint as Garmin TrackPointExtension (gpxtpx:hr) via text surgery that
leaves every other byte of the document untouched, and hands the merged
file back through the share sheet / save panel. Uploading it to the
originating service shows the route AND heart rate / zones there.

Fully offline, file-in/file-out: the store is only read (through the
hrSamples union funnel), nothing is imported or persisted, and every
refusal reports its numbers (no silent unchanged re-export). This is the
ryanbr#2679 option-1 direction: automating the capability without any
third-party credential or network scope change.

- GpxHrInjector (Packages/StrandImport): pure, Linux-safe, 14 tests incl.
  round-trip through ActivityFileImporter and byte-preservation.
- GpxHrInject (android com.noop.ingest): byte-identical Kotlin twin,
  pinned by oracle (Swift stdout over shared fixtures pasted as expected
  literals). The Android Compose entry point is deliberately not in this
  change and is left to a follow-up; the byte-contract layer ships on
  both platforms together.
- DataSourcesView: the merge card, gated file picking, per-failure copy;
  12 new catalog keys translated for all nine shipped locales.

Refs ryanbr#2679

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@andre-fu

Copy link
Copy Markdown
Author

Withdrawing this for now — needs more local testing first. Will resubmit when it's ready.

@andre-fu andre-fu closed this Oct 20, 2026
@andre-fu
andre-fu deleted the strava-hr-merge branch October 20, 2026 08:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant