intervalssync 0.9.4__tar.gz → 0.9.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. {intervalssync-0.9.4 → intervalssync-0.9.5}/.env.example +2 -0
  2. intervalssync-0.9.5/.github/scripts/publish_release.sh +61 -0
  3. {intervalssync-0.9.4 → intervalssync-0.9.5}/.github/workflows/release.yml +10 -15
  4. {intervalssync-0.9.4 → intervalssync-0.9.5}/CLAUDE.md +6 -4
  5. {intervalssync-0.9.4 → intervalssync-0.9.5}/PKG-INFO +3 -1
  6. {intervalssync-0.9.4 → intervalssync-0.9.5}/README.md +2 -0
  7. {intervalssync-0.9.4 → intervalssync-0.9.5}/docs/AGENT.md +7 -1
  8. {intervalssync-0.9.4 → intervalssync-0.9.5}/pyproject.toml +1 -1
  9. intervalssync-0.9.5/src/intervalssync/_version.py +1 -0
  10. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/bryton/core.py +15 -3
  11. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/bryton/ddp.py +5 -2
  12. intervalssync-0.9.5/src/intervalssync/bryton/region.py +57 -0
  13. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/bryton/workout.py +17 -4
  14. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/cli/env.py +15 -2
  15. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/cli/main.py +2 -0
  16. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/config.py +1 -0
  17. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/settings_view.py +16 -1
  18. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/sync_view.py +2 -0
  19. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/igpsport/core.py +36 -4
  20. intervalssync-0.9.5/tests/test_bryton_region.py +85 -0
  21. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_cli.py +44 -1
  22. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_config.py +3 -0
  23. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_igpsport_core.py +110 -1
  24. intervalssync-0.9.4/src/intervalssync/_version.py +0 -1
  25. {intervalssync-0.9.4 → intervalssync-0.9.5}/.github/FUNDING.yml +0 -0
  26. {intervalssync-0.9.4 → intervalssync-0.9.5}/.github/pull_request_template.md +0 -0
  27. {intervalssync-0.9.4 → intervalssync-0.9.5}/.github/workflows/test.yml +0 -0
  28. {intervalssync-0.9.4 → intervalssync-0.9.5}/.gitignore +0 -0
  29. {intervalssync-0.9.4 → intervalssync-0.9.5}/.python-version +0 -0
  30. {intervalssync-0.9.4 → intervalssync-0.9.5}/CONTRIBUTING.md +0 -0
  31. {intervalssync-0.9.4 → intervalssync-0.9.5}/LICENSE +0 -0
  32. {intervalssync-0.9.4 → intervalssync-0.9.5}/assets/icon.png +0 -0
  33. {intervalssync-0.9.4 → intervalssync-0.9.5}/docs/img/demo.gif +0 -0
  34. {intervalssync-0.9.4 → intervalssync-0.9.5}/docs/img/settings.png +0 -0
  35. {intervalssync-0.9.4 → intervalssync-0.9.5}/docs/img/sync.png +0 -0
  36. {intervalssync-0.9.4 → intervalssync-0.9.5}/main.py +0 -0
  37. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/__init__.py +0 -0
  38. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/_dropbox_app_key.py +0 -0
  39. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/bryton/__init__.py +0 -0
  40. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/bryton/api.py +0 -0
  41. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/bryton/exceptions.py +0 -0
  42. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/bryton/fit_encode.py +0 -0
  43. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/cli/__init__.py +0 -0
  44. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/cli/config.py +0 -0
  45. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/dropbox_client.py +0 -0
  46. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/__init__.py +0 -0
  47. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/app.py +0 -0
  48. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/profile_sync_ui.py +0 -0
  49. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/secrets.py +0 -0
  50. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/support_gamification.py +0 -0
  51. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/system.py +0 -0
  52. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/theme.py +0 -0
  53. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/gui/update_check.py +0 -0
  54. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/igpsport/__init__.py +0 -0
  55. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/igpsport/interval_info.py +0 -0
  56. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/igpsport/profile_sync.py +0 -0
  57. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/igpsport/region.py +0 -0
  58. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/igpsport/workout.py +0 -0
  59. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/igpsport/zone_map.py +0 -0
  60. {intervalssync-0.9.4 → intervalssync-0.9.5}/src/intervalssync/intervals_icu.py +0 -0
  61. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/Foundation_2.fit +0 -0
  62. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/Foundation_2.json +0 -0
  63. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/app.fit +0 -0
  64. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/app.json +0 -0
  65. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/web_bsWO0618_011758.fit +0 -0
  66. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/fixtures/bryton_workouts/web_bsWO0618_011758.json +0 -0
  67. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_activity_checkpoints.py +0 -0
  68. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_bryton_core.py +0 -0
  69. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_bryton_fit_encode.py +0 -0
  70. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_bryton_fit_native.py +0 -0
  71. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_bryton_workout.py +0 -0
  72. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_dropbox_client.py +0 -0
  73. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_gui_app.py +0 -0
  74. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_gui_sync.py +0 -0
  75. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_intervals_icu.py +0 -0
  76. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_profile_sync.py +0 -0
  77. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_secrets.py +0 -0
  78. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_support_gamification.py +0 -0
  79. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_update_check.py +0 -0
  80. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_workout.py +0 -0
  81. {intervalssync-0.9.4 → intervalssync-0.9.5}/tests/test_zone_map.py +0 -0
  82. {intervalssync-0.9.4 → intervalssync-0.9.5}/uv.lock +0 -0
@@ -16,4 +16,6 @@ INTERVALSSYNC_IGPSPORT_PASSWORD=
16
16
  INTERVALSSYNC_IGPSPORT_REGION=international
17
17
  INTERVALSSYNC_BRYTON_EMAIL=
18
18
  INTERVALSSYNC_BRYTON_PASSWORD=
19
+ # international (default) or china — use china for the China-region Bryton app
20
+ INTERVALSSYNC_BRYTON_REGION=international
19
21
  INTERVALSSYNC_INTERVALS_API_KEY=
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ tag=${1:?usage: publish_release.sh TAG ASSET_DIRECTORY}
6
+ asset_directory=${2:?usage: publish_release.sh TAG ASSET_DIRECTORY}
7
+
8
+ if [[ ! -d "$asset_directory" ]]; then
9
+ echo "Asset directory does not exist: $asset_directory" >&2
10
+ exit 1
11
+ fi
12
+
13
+ mapfile -d '' assets < <(find "$asset_directory" -type f -print0 | sort -z)
14
+ if (( ${#assets[@]} == 0 )); then
15
+ echo "No release assets found under $asset_directory" >&2
16
+ exit 1
17
+ fi
18
+
19
+ # Creating a draft first prevents users from seeing a release with missing
20
+ # downloads. On a retry, reuse the existing draft and its completed assets.
21
+ if ! gh release view "$tag" >/dev/null 2>&1; then
22
+ gh release create "$tag" --draft --generate-notes --verify-tag
23
+ fi
24
+
25
+ for asset in "${assets[@]}"; do
26
+ name=$(basename "$asset")
27
+
28
+ if gh release view "$tag" --json assets \
29
+ --jq '.assets[] | select(.state == "uploaded") | .name' | grep -Fxq "$name"; then
30
+ echo "Already uploaded: $name"
31
+ continue
32
+ fi
33
+
34
+ uploaded=false
35
+ for attempt in 1 2 3; do
36
+ echo "Uploading $name (attempt $attempt of 3)..."
37
+ if gh release upload "$tag" "$asset" --clobber; then
38
+ uploaded=true
39
+ break
40
+ fi
41
+
42
+ if (( attempt < 3 )); then
43
+ delay=$((attempt * 15))
44
+ echo "Upload failed; retrying $name in ${delay}s..." >&2
45
+ sleep "$delay"
46
+ fi
47
+ done
48
+
49
+ if [[ "$uploaded" != true ]]; then
50
+ echo "Failed to upload $name after 3 attempts." >&2
51
+ exit 1
52
+ fi
53
+ done
54
+
55
+ # Tags with a hyphen are prereleases. Publish only after every asset succeeds,
56
+ # so a failed job remains a recoverable draft rather than a partial release.
57
+ if [[ "$tag" == *-* ]]; then
58
+ gh release edit "$tag" --draft=false --prerelease --latest=false
59
+ else
60
+ gh release edit "$tag" --draft=false --prerelease=false --latest
61
+ fi
@@ -214,22 +214,17 @@ jobs:
214
214
  needs: [windows, android, macos]
215
215
  runs-on: ubuntu-latest
216
216
  steps:
217
+ - uses: actions/checkout@v4
218
+
217
219
  - name: Download build artifacts
218
- uses: actions/download-artifact@v4
220
+ uses: actions/download-artifact@v8
219
221
  with:
220
222
  path: dist
221
223
 
222
- # Create (or update) the Release for this tag, attach all files, and
223
- # generate the notes a single time (avoids the duplicate notes you get
224
- # when each build job generates them).
225
- #
226
- # Tags with a hyphen (e.g. v0.3.0-rc1) are marked as pre-releases: GitHub
227
- # keeps the last stable as "Latest", the in-app update check (which uses
228
- # the /releases/latest API) ignores them, and they aren't offered to users.
229
- - name: Publish Release
230
- uses: softprops/action-gh-release@v2
231
- with:
232
- files: dist/**/*
233
- generate_release_notes: true
234
- prerelease: ${{ contains(github.ref_name, '-') }}
235
- make_latest: ${{ !contains(github.ref_name, '-') }}
224
+ # Keep the release as a draft until every asset is uploaded. The helper
225
+ # uploads one file at a time and retries failures without replacing assets
226
+ # that already completed in an earlier attempt.
227
+ - name: Upload and publish Release
228
+ env:
229
+ GH_TOKEN: ${{ github.token }}
230
+ run: bash .github/scripts/publish_release.sh "${{ github.ref_name }}" dist
@@ -33,7 +33,7 @@ src/intervalssync/
33
33
  cli/main.py, env.py, config.py # headless CLI
34
34
  gui/app.py, views, config.py, secrets.py # Flet UI (dual-source: iGPSPORT + Bryton)
35
35
  igpsport/core.py, workout.py # iGPSPORT source
36
- bryton/ddp.py, api.py, core.py # Bryton source
36
+ bryton/ddp.py, api.py, core.py, region.py # Bryton source
37
37
  intervals_icu.py, dropbox_client.py # shared by sources
38
38
  ```
39
39
 
@@ -41,8 +41,8 @@ src/intervalssync/
41
41
  - **`igpsport/core.py`** — `sync(SyncConfig, progress)`: login → list → FIT URL → download → upload. `external_id`: `igpsport_{ride_id}`.
42
42
  - **`igpsport/profile_sync.py`** — `sync_profile_zones`: intervals.icu sport settings + athlete weight → iGPSPORT profile thresholds/zones/weight.
43
43
  - **`igpsport/workout.py`** — planned workouts intervals.icu → iGPSPORT.
44
- - **`bryton/ddp.py`** — Meteor DDP login + `activityList` subscription.
45
- - **`bryton/api.py`** — `GET https://m3.brytonactive.com/api/activity?id=…` (FIT download; Android app API).
44
+ - **`bryton/ddp.py`** — Meteor DDP login + `activityList` subscription. `region.py` holds the international vs China endpoints.
45
+ - **`bryton/api.py`** — `GET https://{session.host}/api/activity?id=…` (FIT download; Android app API).
46
46
  - **`bryton/core.py`** — `sync(SyncConfig, progress)`. `external_id`: `bryton_{activity_id}`.
47
47
  - **`gui/secrets.py`** — async OS vault via `flet-secure-storage`.
48
48
  - **`gui/config.py`** — GUI JSON settings: `enable_igpsport` / `enable_bryton`, per-source usernames, shared sync options.
@@ -58,9 +58,11 @@ src/intervalssync/
58
58
 
59
59
  ## Bryton API notes
60
60
 
61
- 1. Login: Meteor DDP `login` with email + SHA-256 password digest on `m3.brytonactive.com`.
61
+ 1. Login: Meteor DDP `login` with email + SHA-256 password digest on `m3.brytonactive.com` (international) or `active.cn.brytonactive.com` (China).
62
62
  2. List: subscribe `activityList` → `userActivities` collection.
63
63
  3. FIT: REST `GET /api/activity?id={_id}` with `X-User-Id`, `X-Auth-Token`, static `x-api-key` (not the web `activity.detail.2` samples API).
64
+ 4. Workouts: POST `https://{web_host}/workout/upload/{user_id}` (`active.brytonsport.com` international, `active.cn.brytonactive.com` China).
65
+ 5. **Regions are fully separate account databases.** An account created in one region returns `User not found` in the other. GUI `bryton_region`; CLI `INTERVALSSYNC_BRYTON_REGION=international|china`.
64
66
 
65
67
  ## Flet version note
66
68
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: intervalssync
3
- Version: 0.9.4
3
+ Version: 0.9.5
4
4
  Summary: Sync cycling activities from iGPSPORT or Bryton Active to intervals.icu
5
5
  Project-URL: Homepage, https://github.com/jorge-huxley/intervalssync
6
6
  Project-URL: Repository, https://github.com/jorge-huxley/intervalssync
@@ -87,6 +87,7 @@ Description-Content-Type: text/markdown
87
87
  - **Sync zones to iGPSPORT** — push FTP, LTHR, max HR, weight, and power/HR zones from intervals.icu into your iGPSPORT profile (Settings → iGPSPORT profile, or `intervalssync sync-zones`; the app prompts on launch when thresholds differ)
88
88
  - **Headless CLI** — activity sync, workout upload, and iGPSPORT zone/threshold sync from the terminal, with JSON output and exit codes for automation and AI agents (see [CLI & automation](#cli--automation-ai-agents))
89
89
  - **China iGPSPORT region** — accounts on [app.igpsport.cn](https://app.igpsport.cn/login) use a separate API; choose **China** in Settings (or `INTERVALSSYNC_IGPSPORT_REGION=china` in the CLI)
90
+ - **China Bryton region** — accounts created in the China-region Bryton app use a separate backend; choose **China** in Settings (or `INTERVALSSYNC_BRYTON_REGION=china` in the CLI)
90
91
 
91
92
  > Prefer the terminal, or want to help out? It's open source (Python + [Flet](https://flet.dev), MIT) — see [CONTRIBUTING.md](CONTRIBUTING.md).
92
93
 
@@ -96,6 +97,7 @@ Description-Content-Type: text/markdown
96
97
  2. Unzip it anywhere and double-click the app (e.g. `intervalssync.exe`).
97
98
  3. On first launch, open **Settings** and enter:
98
99
  - your iGPSPORT **email** and **password** (or **phone number** for China — set region to China if you use [app.igpsport.cn](https://app.igpsport.cn/login))
100
+ - for Bryton Active, your **email** and **password** and the matching **region** (International or China)
99
101
  - your intervals.icu **API key** (intervals.icu → Settings → Developer)
100
102
  4. Click **Save**, then go back and press **Sync** (or **Upload to iGPSPORT** / **Upload to Bryton** for planned sessions on your intervals.icu calendar).
101
103
 
@@ -66,6 +66,7 @@
66
66
  - **Sync zones to iGPSPORT** — push FTP, LTHR, max HR, weight, and power/HR zones from intervals.icu into your iGPSPORT profile (Settings → iGPSPORT profile, or `intervalssync sync-zones`; the app prompts on launch when thresholds differ)
67
67
  - **Headless CLI** — activity sync, workout upload, and iGPSPORT zone/threshold sync from the terminal, with JSON output and exit codes for automation and AI agents (see [CLI & automation](#cli--automation-ai-agents))
68
68
  - **China iGPSPORT region** — accounts on [app.igpsport.cn](https://app.igpsport.cn/login) use a separate API; choose **China** in Settings (or `INTERVALSSYNC_IGPSPORT_REGION=china` in the CLI)
69
+ - **China Bryton region** — accounts created in the China-region Bryton app use a separate backend; choose **China** in Settings (or `INTERVALSSYNC_BRYTON_REGION=china` in the CLI)
69
70
 
70
71
  > Prefer the terminal, or want to help out? It's open source (Python + [Flet](https://flet.dev), MIT) — see [CONTRIBUTING.md](CONTRIBUTING.md).
71
72
 
@@ -75,6 +76,7 @@
75
76
  2. Unzip it anywhere and double-click the app (e.g. `intervalssync.exe`).
76
77
  3. On first launch, open **Settings** and enter:
77
78
  - your iGPSPORT **email** and **password** (or **phone number** for China — set region to China if you use [app.igpsport.cn](https://app.igpsport.cn/login))
79
+ - for Bryton Active, your **email** and **password** and the matching **region** (International or China)
78
80
  - your intervals.icu **API key** (intervals.icu → Settings → Developer)
79
81
  4. Click **Save**, then go back and press **Sync** (or **Upload to iGPSPORT** / **Upload to Bryton** for planned sessions on your intervals.icu calendar).
80
82
 
@@ -43,6 +43,12 @@ echo 'INTERVALSSYNC_INTERVALS_API_KEY=your-intervals-api-key' >> ~/.hermes/.env
43
43
  chmod 600 ~/.hermes/.env
44
44
  ```
45
45
 
46
+ For **China-region** accounts (the 百锐腾运动 app / [www.brytonsports.cn](http://www.brytonsports.cn)), add:
47
+
48
+ ```bash
49
+ echo 'INTERVALSSYNC_BRYTON_REGION=china' >> ~/.hermes/.env
50
+ ```
51
+
46
52
  For a named Hermes profile, use `$HERMES_HOME/.env` (that profile's directory).
47
53
 
48
54
  **intervals.icu API key:** intervals.icu → Settings → Developer.
@@ -176,7 +182,7 @@ Non-secret defaults in `intervalssync-cli` `config.json` (`platformdirs`). Secre
176
182
  ### Activity sync
177
183
 
178
184
  - **iGPSPORT:** list → FIT URL → download → upload (`igpsport_{ride_id}` external_id).
179
- - **Bryton:** DDP login → activity list → `GET /api/activity?id=…` FIT → upload (`bryton_{id}` external_id).
185
+ - **Bryton:** DDP login → activity list → `GET /api/activity?id=…` FIT → upload (`bryton_{id}` external_id). International uses `m3.brytonactive.com`; China uses `active.cn.brytonactive.com` (set `INTERVALSSYNC_BRYTON_REGION=china`).
180
186
  - Skips already on intervals.icu unless `--force-resync`.
181
187
  - **Dropbox** is GUI-only (Settings → connect Dropbox, enable upload). iGPSPORT
182
188
  uses `ride-0-YYYY-MM-DD-HH-MM-SS.fit` or `igpsport_{id}.fit`; Bryton uses
@@ -2,7 +2,7 @@
2
2
  name = "intervalssync"
3
3
  # Placeholder. The real version comes from the git tag, injected by CI at build
4
4
  # time (see .github/workflows/release.yml). Local/dev builds report 0.0.0+dev.
5
- version = "0.9.4"
5
+ version = "0.9.5"
6
6
  description = "Sync cycling activities from iGPSPORT or Bryton Active to intervals.icu"
7
7
  readme = "README.md"
8
8
  license = "MIT"
@@ -0,0 +1 @@
1
+ __version__ = "0.9.5"
@@ -19,8 +19,9 @@ from ..intervals_icu import (
19
19
  upload_fit_file,
20
20
  )
21
21
  from .api import download_fit_to_path
22
- from .ddp import DEFAULT_HOST, BrytonSession, list_activities, login
22
+ from .ddp import BrytonSession, list_activities, login
23
23
  from .exceptions import BrytonSyncError
24
+ from .region import resolve_region
24
25
 
25
26
  ACTIVITY_TYPE_KEEP = ""
26
27
 
@@ -90,7 +91,9 @@ class SyncConfig:
90
91
  intervals_api_key: str | None
91
92
  dropbox_refresh_token: str | None = None
92
93
  dropbox_app_key: str | None = None
93
- host: str = DEFAULT_HOST
94
+ # Explicit endpoint override; normally resolved from bryton_region.
95
+ host: str | None = None
96
+ bryton_region: str = "international"
94
97
  max_activities: int = 5
95
98
  download_dir: Path = Path("downloads")
96
99
  delete_after_upload: bool = True
@@ -118,8 +121,17 @@ def sync(config: SyncConfig, progress: Progress | None = None) -> SyncResult:
118
121
  report = progress or _noop
119
122
  result = SyncResult()
120
123
 
124
+ try:
125
+ region = resolve_region(config.bryton_region)
126
+ except ValueError as exc:
127
+ raise BrytonSyncError(str(exc)) from exc
128
+
121
129
  report("Logging in to Bryton Active…")
122
- session = login(config.bryton_email, config.bryton_password, host=config.host)
130
+ session = login(
131
+ config.bryton_email,
132
+ config.bryton_password,
133
+ host=config.host or region.host,
134
+ )
123
135
  report("Logged in.")
124
136
 
125
137
  raw_activities = list_activities(session, limit=config.max_activities)
@@ -12,9 +12,12 @@ from typing import Any
12
12
  import requests
13
13
 
14
14
  from .exceptions import BrytonAuthError, BrytonDDPError
15
+ from .region import INTERNATIONAL
15
16
 
16
- DEFAULT_HOST = "m3.brytonactive.com"
17
- WEB_HOST = "active.brytonsport.com"
17
+ # Backwards-compatible aliases for the international endpoints. Region-aware
18
+ # callers should resolve a BrytonRegionConfig instead.
19
+ DEFAULT_HOST = INTERNATIONAL.host
20
+ WEB_HOST = INTERNATIONAL.web_host
18
21
  DDP_CONNECT = {"msg": "connect", "version": "1", "support": ["1", "pre2", "pre1"]}
19
22
 
20
23
 
@@ -0,0 +1,57 @@
1
+ """Bryton Active regional endpoints (international vs China).
2
+
3
+ Bryton runs fully separate account databases and Meteor deployments for its
4
+ international and China-region apps. An account that exists on one region's
5
+ server reports ``User not found`` on the other, so the user has to pick the
6
+ region where the account was created.
7
+
8
+ The China backend was found through the regional web clients:
9
+ ``www.brytonsports.cn`` (百锐腾运动) talks to ``active.cn.brytonactive.com``,
10
+ which serves the same DDP API, mobile FIT endpoint and workout upload route as
11
+ the international deployment.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import Literal
18
+
19
+ BrytonRegionName = Literal["international", "china"]
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class BrytonRegionConfig:
24
+ name: BrytonRegionName
25
+ # Meteor DDP + mobile REST API (``GET /api/activity``).
26
+ host: str
27
+ # Web backend used for the planned-workout upload (``/workout/upload``).
28
+ web_host: str
29
+
30
+
31
+ INTERNATIONAL = BrytonRegionConfig(
32
+ name="international",
33
+ host="m3.brytonactive.com",
34
+ web_host="active.brytonsport.com",
35
+ )
36
+
37
+ CHINA = BrytonRegionConfig(
38
+ name="china",
39
+ host="active.cn.brytonactive.com",
40
+ web_host="active.cn.brytonactive.com",
41
+ )
42
+
43
+ _REGIONS: dict[BrytonRegionName, BrytonRegionConfig] = {
44
+ "international": INTERNATIONAL,
45
+ "china": CHINA,
46
+ }
47
+
48
+
49
+ def resolve_region(name: str | None) -> BrytonRegionConfig:
50
+ """Return the region config for a name; default is international."""
51
+ if not name or name == "international":
52
+ return INTERNATIONAL
53
+ if name == "china":
54
+ return CHINA
55
+ raise ValueError(
56
+ f"Unknown Bryton region: {name!r} (expected 'international' or 'china')"
57
+ )
@@ -17,13 +17,14 @@ from uuid import uuid4
17
17
  import requests
18
18
 
19
19
  from .. import intervals_icu
20
- from .ddp import WEB_HOST, BrytonSession, call_method, login
20
+ from .ddp import BrytonSession, call_method, login
21
21
  from .exceptions import BrytonSyncError
22
22
  from .fit_encode import (
23
23
  bryton_hr_uses_mhr,
24
24
  encoded_step_count,
25
25
  icu_workout_doc_to_bryton_fit,
26
26
  )
27
+ from .region import INTERNATIONAL, resolve_region
27
28
 
28
29
  _CYCLING_TYPES = frozenset(
29
30
  {
@@ -95,6 +96,9 @@ class BrytonWorkoutUploadConfig:
95
96
  workout_days_ahead: int = 1
96
97
  uploaded_workouts: dict[str, str] = field(default_factory=dict)
97
98
  force_resync: bool = False
99
+ bryton_region: str = "international"
100
+ # Explicit endpoint override; normally resolved from bryton_region.
101
+ web_host: str | None = None
98
102
 
99
103
 
100
104
  def apply_uploaded_bryton_workout_map(
@@ -265,9 +269,9 @@ def upload_workout_fit(
265
269
  return resp.ok
266
270
 
267
271
 
268
- def web_login(email: str, password: str) -> BrytonSession:
272
+ def web_login(email: str, password: str, *, host: str | None = None) -> BrytonSession:
269
273
  """Log in to the Bryton Active website backend."""
270
- return login(email, password, host=WEB_HOST)
274
+ return login(email, password, host=host or INTERNATIONAL.web_host)
271
275
 
272
276
 
273
277
  def upload_workouts(
@@ -283,8 +287,17 @@ def upload_workouts(
283
287
  days = max(1, config.workout_days_ahead)
284
288
  newest = config.newest or (today + timedelta(days=days - 1))
285
289
 
290
+ try:
291
+ region = resolve_region(config.bryton_region)
292
+ except ValueError as exc:
293
+ raise BrytonSyncError(str(exc)) from exc
294
+
286
295
  report("Logging in to Bryton Active…")
287
- session = web_login(config.bryton_email, config.bryton_password)
296
+ session = web_login(
297
+ config.bryton_email,
298
+ config.bryton_password,
299
+ host=config.web_host or region.web_host,
300
+ )
288
301
  report("Logged in.")
289
302
 
290
303
  live_ids, live_names = _fetch_workout_library(session)
@@ -14,6 +14,7 @@ IGPSPORT_PASSWORD_KEY = "INTERVALSSYNC_IGPSPORT_PASSWORD"
14
14
  IGPSPORT_REGION_KEY = "INTERVALSSYNC_IGPSPORT_REGION"
15
15
  BRYTON_EMAIL_KEY = "INTERVALSSYNC_BRYTON_EMAIL"
16
16
  BRYTON_PASSWORD_KEY = "INTERVALSSYNC_BRYTON_PASSWORD"
17
+ BRYTON_REGION_KEY = "INTERVALSSYNC_BRYTON_REGION"
17
18
  INTERVALS_API_KEY_KEY = "INTERVALSSYNC_INTERVALS_API_KEY"
18
19
 
19
20
  IGPSPORT_REQUIRED_KEYS = (
@@ -31,12 +32,15 @@ _IGPSPORT_BODY = (
31
32
  f" {IGPSPORT_USER_KEY}=<email>\n"
32
33
  f" {IGPSPORT_PASSWORD_KEY}=<password>\n"
33
34
  f" {INTERVALS_API_KEY_KEY}=<api-key>\n"
34
- f" {IGPSPORT_REGION_KEY}=international # or china for app.igpsport.cn"
35
+ f" # optional: international (default) or china for app.igpsport.cn\n"
36
+ f" {IGPSPORT_REGION_KEY}=international"
35
37
  )
36
38
  _BRYTON_BODY = (
37
39
  f" {BRYTON_EMAIL_KEY}=<email>\n"
38
40
  f" {BRYTON_PASSWORD_KEY}=<password>\n"
39
- f" {INTERVALS_API_KEY_KEY}=<api-key>"
41
+ f" {INTERVALS_API_KEY_KEY}=<api-key>\n"
42
+ f" # optional: international (default) or china for the China-region Bryton app\n"
43
+ f" {BRYTON_REGION_KEY}=international"
40
44
  )
41
45
 
42
46
  ENV_OVERRIDE_HINT = (
@@ -77,6 +81,7 @@ class BrytonCredentials:
77
81
  bryton_email: str
78
82
  bryton_password: str
79
83
  intervals_api_key: str
84
+ bryton_region: str = "international"
80
85
 
81
86
 
82
87
  def _default_hermes_env_path() -> Path:
@@ -175,10 +180,18 @@ def load_bryton_credentials(env_path: Path) -> BrytonCredentials:
175
180
  if missing:
176
181
  raise CliConfigError(_missing_keys_message(env_path, missing, source="bryton"))
177
182
 
183
+ region = values.get(BRYTON_REGION_KEY, "international") or "international"
184
+ if region not in ("international", "china"):
185
+ raise CliConfigError(
186
+ f"Invalid {BRYTON_REGION_KEY}={region!r} in {env_path} "
187
+ "(expected 'international' or 'china')."
188
+ )
189
+
178
190
  return BrytonCredentials(
179
191
  bryton_email=values[BRYTON_EMAIL_KEY],
180
192
  bryton_password=values[BRYTON_PASSWORD_KEY],
181
193
  intervals_api_key=values[INTERVALS_API_KEY_KEY],
194
+ bryton_region=region,
182
195
  )
183
196
 
184
197
 
@@ -93,6 +93,7 @@ def _build_bryton_sync_config(
93
93
  bryton_email=credentials.bryton_email,
94
94
  bryton_password=credentials.bryton_password,
95
95
  intervals_api_key=credentials.intervals_api_key,
96
+ bryton_region=credentials.bryton_region,
96
97
  max_activities=max_activities if max_activities is not None else config.max_activities,
97
98
  download_dir=Path(download_dir or config.download_dir),
98
99
  delete_after_upload=(
@@ -208,6 +209,7 @@ def _build_bryton_workout_upload_config(
208
209
  bryton_email=credentials.bryton_email,
209
210
  bryton_password=credentials.bryton_password,
210
211
  intervals_api_key=credentials.intervals_api_key,
212
+ bryton_region=credentials.bryton_region,
211
213
  workout_days_ahead=(
212
214
  workout_days_ahead if workout_days_ahead is not None else config.workout_days_ahead
213
215
  ),
@@ -30,6 +30,7 @@ class AppConfig:
30
30
  igp_user: str = ""
31
31
  igp_region: str = "international"
32
32
  bryton_user: str = ""
33
+ bryton_region: str = "international"
33
34
  max_activities: int = 5
34
35
  download_dir: str = field(default_factory=_default_download_dir)
35
36
  # Remove each .fit file once it has been uploaded to intervals.icu.
@@ -96,6 +96,20 @@ async def build_settings_view(
96
96
  value=config.enable_bryton,
97
97
  active_color=colors["accent"],
98
98
  )
99
+ bryton_region_value = (
100
+ config.bryton_region
101
+ if config.bryton_region in ("international", "china")
102
+ else "international"
103
+ )
104
+ bryton_region = ft.Dropdown(
105
+ label="Bryton region",
106
+ value=bryton_region_value,
107
+ options=[
108
+ ft.dropdown.Option("international", "International"),
109
+ ft.dropdown.Option("china", "China"),
110
+ ],
111
+ helper_text="Use China if your account was created in the China-region Bryton app",
112
+ )
99
113
  bryton_user = _input_field(
100
114
  label="Bryton Active email",
101
115
  value=config.bryton_user,
@@ -519,7 +533,7 @@ async def build_settings_view(
519
533
  )
520
534
  bryton_credentials = ft.Column(
521
535
  spacing=theme.SPACE_SM,
522
- controls=[bryton_user, bryton_password],
536
+ controls=[bryton_region, bryton_user, bryton_password],
523
537
  )
524
538
  workout_sync_section = ft.Container(
525
539
  visible=config.enable_igpsport or config.enable_bryton,
@@ -598,6 +612,7 @@ async def build_settings_view(
598
612
  config.igp_user = igp_user.value.strip()
599
613
  config.igp_region = igp_region.value or "international"
600
614
  config.bryton_user = bryton_user.value.strip()
615
+ config.bryton_region = bryton_region.value or "international"
601
616
  try:
602
617
  config.max_activities = max(1, int(max_activities.value))
603
618
  except (TypeError, ValueError):
@@ -258,6 +258,7 @@ def build_sync_view(
258
258
  intervals_api_key=api_key,
259
259
  dropbox_refresh_token=dropbox_refresh_token,
260
260
  dropbox_app_key=dropbox_app_key,
261
+ bryton_region=config.bryton_region,
261
262
  max_activities=config.max_activities,
262
263
  download_dir=Path(config.download_dir),
263
264
  delete_after_upload=config.delete_after_upload,
@@ -335,6 +336,7 @@ def build_sync_view(
335
336
  workout_days_ahead=config.workout_days_ahead,
336
337
  uploaded_workouts=dict(config.uploaded_bryton_workouts),
337
338
  force_resync=config.force_resync,
339
+ bryton_region=config.bryton_region,
338
340
  )
339
341
 
340
342
  uploaded_count = 0
@@ -51,12 +51,14 @@ IGP_TIME_FORMATS = (
51
51
  )
52
52
 
53
53
 
54
- def parse_igp_start_time(value: str) -> datetime | None:
54
+ def parse_igp_start_time(value: str, *, require_time: bool = False) -> datetime | None:
55
55
  """Parse an iGPSPORT activity start time; return None if unrecognised."""
56
56
  if not value or not isinstance(value, str):
57
57
  return None
58
58
  text = value.strip()
59
59
  for fmt in IGP_TIME_FORMATS:
60
+ if require_time and "%H" not in fmt:
61
+ continue
60
62
  try:
61
63
  return datetime.strptime(text, fmt)
62
64
  except ValueError:
@@ -95,7 +97,7 @@ def dropbox_filename_for(activity: "Activity", use_date: bool = True) -> str:
95
97
  fallback = f"{external_id_for(activity.ride_id)}.fit"
96
98
  if not use_date:
97
99
  return fallback
98
- start = parse_igp_start_time(activity.start_time)
100
+ start = parse_igp_start_time(activity.start_time, require_time=True)
99
101
  if start is None:
100
102
  return fallback
101
103
  return f"ride-0-{start:%Y-%m-%d-%H-%M-%S}.fit"
@@ -114,6 +116,7 @@ class Activity:
114
116
  ride_id: int
115
117
  title: str
116
118
  start_time: str
119
+ fit_url: str | None = field(default=None, repr=False, compare=False)
117
120
 
118
121
 
119
122
  @dataclass
@@ -204,7 +207,36 @@ def list_activities(
204
207
  ) -> list[Activity]:
205
208
  """Return the most recent activities, newest first, capped at max_activities."""
206
209
  cfg = resolve_region(region.name if isinstance(region, IgpRegionConfig) else region)
207
- return _list_activities_query(session, max_activities, cfg)
210
+ activities = _list_activities_query(session, max_activities, cfg)
211
+ # The list endpoint can return only YYYY.MM.DD. Enrich the shared Activity
212
+ # before logging, CLI JSON, date-range checks, or Dropbox skip decisions.
213
+ for activity in activities:
214
+ if parse_igp_start_time(activity.start_time, require_time=True) is not None:
215
+ continue
216
+ try:
217
+ detail = fetch_activity_detail(session, activity.ride_id, cfg)
218
+ except (requests.RequestException, ValueError):
219
+ # Keep the list date for date-range checks. Dropbox uses the unique
220
+ # ride ID when the full timestamp is unavailable, never fake midnight.
221
+ continue
222
+ start_time = detail.get("startTime") or detail.get("StartTime")
223
+ if parse_igp_start_time(start_time, require_time=True) is not None:
224
+ activity.start_time = start_time
225
+ activity.fit_url = detail.get("fitUrl")
226
+ return activities
227
+
228
+
229
+ def fetch_activity_detail(
230
+ session: requests.Session, ride_id: int, region: IgpRegionConfig
231
+ ) -> dict:
232
+ """Fetch the full start timestamp and FIT URL from the activity detail API."""
233
+ response = session.get(
234
+ f"{region.gateway_base}/queryActivityDetail/{ride_id}", timeout=30
235
+ )
236
+ response.raise_for_status()
237
+ body = response.json()
238
+ data = body.get("data") if isinstance(body, dict) else None
239
+ return data if isinstance(data, dict) else {}
208
240
 
209
241
 
210
242
  def _list_activities_query(
@@ -514,7 +546,7 @@ def sync(
514
546
  if any_upload_enabled and not intervals_needs_it and not dropbox_needs_it:
515
547
  continue
516
548
 
517
- fit_url = resolve_fit_url(session, auth_headers, act.ride_id, region)
549
+ fit_url = act.fit_url or resolve_fit_url(session, auth_headers, act.ride_id, region)
518
550
  if not fit_url:
519
551
  report(f"⚠ Could not resolve FIT URL for {act.ride_id}; skipping.")
520
552
  result.failed += 1
@@ -0,0 +1,85 @@
1
+ """Tests for Bryton regional endpoint selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from intervalssync.bryton import core, workout
8
+ from intervalssync.bryton.ddp import BrytonSession
9
+ from intervalssync.bryton.exceptions import BrytonSyncError
10
+ from intervalssync.bryton.region import CHINA, INTERNATIONAL, resolve_region
11
+
12
+
13
+ def test_resolve_region_defaults_to_international():
14
+ assert resolve_region(None) is INTERNATIONAL
15
+ assert resolve_region("") is INTERNATIONAL
16
+ assert resolve_region("international") is INTERNATIONAL
17
+
18
+
19
+ def test_resolve_region_china():
20
+ assert resolve_region("china") is CHINA
21
+ assert CHINA.host == "active.cn.brytonactive.com"
22
+ assert CHINA.web_host == "active.cn.brytonactive.com"
23
+
24
+
25
+ def test_resolve_region_rejects_unknown_name():
26
+ with pytest.raises(ValueError, match="Unknown Bryton region"):
27
+ resolve_region("eu")
28
+
29
+
30
+ def test_sync_logs_in_to_china_host(monkeypatch, tmp_path):
31
+ captured: dict[str, str] = {}
32
+
33
+ def fake_login(email, password, *, host):
34
+ captured["host"] = host
35
+ return BrytonSession(user_id="u1", auth_token="tok", host=host)
36
+
37
+ monkeypatch.setattr(core, "login", fake_login)
38
+ monkeypatch.setattr(core, "list_activities", lambda *a, **k: [])
39
+
40
+ cfg = core.SyncConfig(
41
+ bryton_email="a@b.com",
42
+ bryton_password="pw",
43
+ intervals_api_key="key",
44
+ bryton_region="china",
45
+ download_dir=tmp_path,
46
+ list_activities=False,
47
+ download_fit=False,
48
+ upload_intervals=False,
49
+ )
50
+ core.sync(cfg)
51
+
52
+ assert captured["host"] == CHINA.host
53
+
54
+
55
+ def test_sync_rejects_unknown_region(tmp_path):
56
+ cfg = core.SyncConfig(
57
+ bryton_email="a@b.com",
58
+ bryton_password="pw",
59
+ intervals_api_key="key",
60
+ bryton_region="eu",
61
+ download_dir=tmp_path,
62
+ )
63
+ with pytest.raises(BrytonSyncError, match="Unknown Bryton region"):
64
+ core.sync(cfg)
65
+
66
+
67
+ def test_workout_upload_uses_china_web_host(monkeypatch):
68
+ captured: dict[str, str] = {}
69
+
70
+ def fake_web_login(email, password, *, host=None):
71
+ captured["host"] = host or ""
72
+ raise BrytonSyncError("stop before network")
73
+
74
+ monkeypatch.setattr(workout, "web_login", fake_web_login)
75
+ cfg = workout.BrytonWorkoutUploadConfig(
76
+ bryton_email="a@b.com",
77
+ bryton_password="pw",
78
+ intervals_api_key="key",
79
+ bryton_region="china",
80
+ )
81
+
82
+ with pytest.raises(BrytonSyncError, match="stop before network"):
83
+ workout.upload_workouts(cfg)
84
+
85
+ assert captured["host"] == CHINA.web_host
@@ -66,6 +66,27 @@ def test_load_igpsport_credentials_rejects_invalid_region(tmp_path):
66
66
  cli_env.load_igpsport_credentials(env_file)
67
67
 
68
68
 
69
+ def test_load_bryton_credentials_defaults_region(tmp_path):
70
+ env_file = tmp_path / ".env"
71
+ _write_bryton_env(env_file)
72
+ creds = cli_env.load_bryton_credentials(env_file)
73
+ assert creds.bryton_region == "international"
74
+
75
+
76
+ def test_load_bryton_credentials_china_region(tmp_path):
77
+ env_file = tmp_path / ".env"
78
+ _write_bryton_env(env_file, **{cli_env.BRYTON_REGION_KEY: "china"})
79
+ creds = cli_env.load_bryton_credentials(env_file)
80
+ assert creds.bryton_region == "china"
81
+
82
+
83
+ def test_load_bryton_credentials_rejects_invalid_region(tmp_path):
84
+ env_file = tmp_path / ".env"
85
+ _write_bryton_env(env_file, **{cli_env.BRYTON_REGION_KEY: "eu"})
86
+ with pytest.raises(cli_env.CliConfigError, match="INTERVALSSYNC_BRYTON_REGION"):
87
+ cli_env.load_bryton_credentials(env_file)
88
+
89
+
69
90
  def test_parse_dotenv_comments_and_quotes():
70
91
  text = """
71
92
  # comment
@@ -356,13 +377,35 @@ def test_upload_workouts_json_sync_error(tmp_path: Path, monkeypatch, capsys):
356
377
  assert payload["error"] == "fetch failed"
357
378
 
358
379
 
380
+ def test_sync_bryton_passes_region(tmp_path: Path, monkeypatch, capsys):
381
+ env_file = tmp_path / ".env"
382
+ _write_bryton_env(env_file, **{cli_env.BRYTON_REGION_KEY: "china"})
383
+ monkeypatch.setattr(cli_config, "CONFIG_DIR", tmp_path)
384
+ monkeypatch.setattr(cli_config, "CONFIG_PATH", tmp_path / "config.json")
385
+
386
+ def fake_sync(config, progress=None):
387
+ assert config.bryton_region == "china"
388
+ return cli.BrytonSyncResult()
389
+
390
+ monkeypatch.setattr(cli, "bryton_sync", fake_sync)
391
+
392
+ args = cli._build_parser().parse_args(
393
+ ["sync", "--source", "bryton", "--env-file", str(env_file), "--json"]
394
+ )
395
+ assert cli.cmd_sync(args) == cli.EXIT_OK
396
+
397
+ payload = json.loads(capsys.readouterr().out)
398
+ assert payload["source"] == "bryton"
399
+
400
+
359
401
  def test_upload_workouts_bryton_json_success(tmp_path: Path, monkeypatch, capsys):
360
402
  env_file = tmp_path / ".env"
361
- _write_bryton_env(env_file)
403
+ _write_bryton_env(env_file, **{cli_env.BRYTON_REGION_KEY: "china"})
362
404
  monkeypatch.setattr(cli_config, "CONFIG_DIR", tmp_path)
363
405
  monkeypatch.setattr(cli_config, "CONFIG_PATH", tmp_path / "config.json")
364
406
 
365
407
  def fake_upload(config, progress=None):
408
+ assert config.bryton_region == "china"
366
409
  if progress:
367
410
  progress("uploading bryton")
368
411
  return bryton_workout.BrytonWorkoutUploadResult(
@@ -12,6 +12,7 @@ def test_defaults():
12
12
  assert cfg.enable_igpsport is True
13
13
  assert cfg.igp_region == "international"
14
14
  assert cfg.enable_bryton is False
15
+ assert cfg.bryton_region == "international"
15
16
  assert cfg.delete_after_upload is True
16
17
  assert cfg.force_resync is False
17
18
  assert cfg.activity_type == "" # "" = leave the uploaded sport untouched
@@ -81,6 +82,7 @@ def test_save_and_load_bryton_flags(tmp_path, monkeypatch):
81
82
  enable_igpsport=False,
82
83
  enable_bryton=True,
83
84
  bryton_user="rider@example.com",
85
+ bryton_region="china",
84
86
  )
85
87
  config_module.save(cfg)
86
88
 
@@ -88,6 +90,7 @@ def test_save_and_load_bryton_flags(tmp_path, monkeypatch):
88
90
  assert loaded.enable_igpsport is False
89
91
  assert loaded.enable_bryton is True
90
92
  assert loaded.bryton_user == "rider@example.com"
93
+ assert loaded.bryton_region == "china"
91
94
  assert "activity_source" not in json.loads(path.read_text(encoding="utf-8"))
92
95
 
93
96
 
@@ -81,6 +81,11 @@ def test_parse_igp_start_time_formats():
81
81
 
82
82
  def test_dropbox_filename_for_dotted_date():
83
83
  act = core.Activity(1, "Ride", "2026.07.19")
84
+ assert core.dropbox_filename_for(act) == "igpsport_1.fit"
85
+
86
+
87
+ def test_dropbox_filename_preserves_actual_midnight():
88
+ act = core.Activity(1, "Ride", "2026-07-19 00:00:00")
84
89
  assert core.dropbox_filename_for(act) == "ride-0-2026-07-19-00-00-00.fit"
85
90
 
86
91
 
@@ -474,7 +479,7 @@ def test_list_activities_paginates_when_api_caps_page_size():
474
479
 
475
480
  def test_list_activities_caps_when_server_returns_extra():
476
481
  class FakeSession:
477
- def get(self, url, params=None):
482
+ def get(self, url, params=None, **kwargs):
478
483
  rows = [{"rideId": i, "title": "", "startTime": ""} for i in range(20)]
479
484
  return FakeResponse(json_data={"data": {"rows": rows}})
480
485
 
@@ -482,6 +487,68 @@ def test_list_activities_caps_when_server_returns_extra():
482
487
  assert len(acts) == 5
483
488
 
484
489
 
490
+ @pytest.mark.parametrize("region", ["international", "china"])
491
+ def test_list_activities_enriches_date_only_times_from_detail(region):
492
+ calls = []
493
+
494
+ class FakeSession:
495
+ def get(self, url, params=None, **kwargs):
496
+ calls.append(url)
497
+ if "queryMyActivity" in url:
498
+ return FakeResponse(json_data={"data": {"rows": [
499
+ {"rideId": 1, "startTime": "2026.10.27"},
500
+ {"rideId": 2, "startTime": "2026.10.27"},
501
+ ]}})
502
+ ride_id = int(url.rsplit("/", 1)[1])
503
+ return FakeResponse(json_data={"data": {
504
+ "startTime": f"2026-10-27 {ride_id + 7:02}:42:08",
505
+ "fitUrl": f"https://fit.test/{ride_id}.fit",
506
+ }})
507
+
508
+ activities = core.list_activities(FakeSession(), 2, region)
509
+ assert [act.start_time for act in activities] == [
510
+ "2026-10-27 08:42:08", "2026-10-27 09:42:08",
511
+ ]
512
+ assert [core.dropbox_filename_for(act) for act in activities] == [
513
+ "ride-0-2026-10-27-08-42-08.fit", "ride-0-2026-10-27-09-42-08.fit",
514
+ ]
515
+ assert activities[0].fit_url == "https://fit.test/1.fit"
516
+ assert len(calls) == 3
517
+ assert all(url.startswith(core.resolve_region(region).gateway_base) for url in calls)
518
+
519
+
520
+ @pytest.mark.parametrize("detail", [None, [], {"startTime": "2026.10.27"},
521
+ {"startTime": "invalid"}])
522
+ def test_list_activities_keeps_date_without_inventing_time(detail):
523
+ class FakeSession:
524
+ def get(self, url, **kwargs):
525
+ if "queryMyActivity" in url:
526
+ return FakeResponse(json_data={"data": {"rows": [
527
+ {"rideId": 1, "startTime": "2026.10.27"},
528
+ ]}})
529
+ return FakeResponse(json_data={"data": detail})
530
+
531
+ activity, = core.list_activities(FakeSession(), 1)
532
+ assert activity.start_time == "2026.10.27"
533
+ assert core.dropbox_filename_for(activity) == "igpsport_1.fit"
534
+ assert core._activity_date_range([activity]) == (date(2026, 10, 26), date(2026, 10, 28))
535
+
536
+
537
+ @pytest.mark.parametrize("error", [core.requests.Timeout(), ValueError("bad JSON")])
538
+ def test_list_activities_survives_unavailable_detail(error):
539
+ class FakeSession:
540
+ def get(self, url, **kwargs):
541
+ if "queryMyActivity" in url:
542
+ return FakeResponse(json_data={"data": {"rows": [
543
+ {"rideId": 1, "startTime": "2026.10.27"},
544
+ ]}})
545
+ raise error
546
+
547
+ activity, = core.list_activities(FakeSession(), 1)
548
+ assert activity.start_time == "2026.10.27"
549
+ assert core.dropbox_filename_for(activity) == "igpsport_1.fit"
550
+
551
+
485
552
  # --------------------------------------------------------------------------- #
486
553
  # sync() orchestration
487
554
  # --------------------------------------------------------------------------- #
@@ -490,6 +557,7 @@ def test_list_activities_caps_when_server_returns_extra():
490
557
  def stub_sync(monkeypatch, tmp_path):
491
558
  """Replace every network leaf so sync() runs offline. Returns a recorder."""
492
559
  rec = {
560
+ "list_activities": core.list_activities,
493
561
  "downloaded": [],
494
562
  "uploaded": [],
495
563
  "dropbox": [],
@@ -598,6 +666,47 @@ def _config(tmp_path, **overrides):
598
666
  return core.SyncConfig(**base)
599
667
 
600
668
 
669
+ def test_sync_uses_detail_times_for_output_and_dropbox_skip_checks(monkeypatch, stub_sync):
670
+ from intervalssync.cli.main import _igpsport_result_payload
671
+
672
+ class FakeSession:
673
+ def get(self, url, **kwargs):
674
+ if "queryMyActivity" in url:
675
+ return FakeResponse(json_data={"data": {"rows": [
676
+ {"rideId": 1, "startTime": "2026.10.27"},
677
+ {"rideId": 2, "startTime": "2026.10.27"},
678
+ ]}})
679
+ ride_id = int(url.rsplit("/", 1)[1])
680
+ return FakeResponse(json_data={"data": {
681
+ "startTime": f"2026-10-27 {ride_id + 7:02}:42:08",
682
+ "fitUrl": f"https://fit.test/{ride_id}.fit",
683
+ }})
684
+
685
+ monkeypatch.setattr(core.requests, "Session", FakeSession)
686
+ monkeypatch.setattr(core, "list_activities", stub_sync["list_activities"])
687
+
688
+ def unexpected_resolve(*args):
689
+ pytest.fail("The FIT URL from the detail response should be reused")
690
+
691
+ monkeypatch.setattr(core, "resolve_fit_url", unexpected_resolve)
692
+ stub_sync["dropbox_existing"] = {
693
+ "ride-0-2026-10-27-00-00-00.fit",
694
+ "ride-0-2026-10-27-08-42-08.fit",
695
+ }
696
+ messages = []
697
+ result = core.sync(_config(
698
+ stub_sync["tmp"], max_activities=2, list_activities=True,
699
+ upload_intervals=False, upload_dropbox=True,
700
+ ), messages.append)
701
+ assert result.skipped_dropbox == 1
702
+ assert result.uploaded_dropbox == 1
703
+ assert stub_sync["dropbox"][0][0] == "ride-0-2026-10-27-09-42-08.fit"
704
+ assert stub_sync["downloaded"] == ["igpsport_2.fit"]
705
+ assert any("2026-10-27 09:42:08" in message for message in messages)
706
+ payload = _igpsport_result_payload(result, ok=True)
707
+ assert payload["activities"][1]["start_time"] == "2026-10-27 09:42:08"
708
+
709
+
601
710
  def test_sync_rejects_malformed_activity_discovery_before_fit_work(monkeypatch, tmp_path):
602
711
  resolved = []
603
712
  downloaded = []
@@ -1 +0,0 @@
1
- __version__ = "0.9.4"
File without changes
File without changes
File without changes
File without changes