From c6df4ed03fc18ddde4c4fb5a87bf4b8259659a5f Mon Sep 17 00:00:00 2001 From: Jorge Silva Date: Mon, 20 Jul 2026 11:57:57 +0100 Subject: [PATCH 1/2] fix(igpsport): paginate activity list beyond 20-item API page cap queryMyActivity returns at most 20 rows per page, so a single request silently capped syncs regardless of --max-activities. Co-authored-by: Cursor --- src/intervalssync/igpsport/core.py | 85 ++++++++++++++++++------------ tests/test_igpsport_core.py | 55 +++++++++++++++---- 2 files changed, 96 insertions(+), 44 deletions(-) diff --git a/src/intervalssync/igpsport/core.py b/src/intervalssync/igpsport/core.py index 3f57cc3..6e4df3b 100644 --- a/src/intervalssync/igpsport/core.py +++ b/src/intervalssync/igpsport/core.py @@ -187,42 +187,59 @@ def _list_activities_query( max_activities: int, region: IgpRegionConfig, ) -> list[Activity]: - """queryMyActivity — camelCase rideId in data.rows (China and new intl accounts).""" - resp = session.get( - region.activity_query_url, - params={ - "pageNo": "1", - "pageSize": str(max_activities), - "sort": "1", - "reqType": "0", - }, - ) - resp.raise_for_status() - - data = resp.json().get("data") or {} - rows = (data.get("rows") if isinstance(data, dict) else None) or [] + """queryMyActivity across pages until max is reached or no more rows exist.""" + # API responses are effectively paged in chunks of up to 20 items. + page_size = min(max_activities, 20) activities: list[Activity] = [] - for item in rows[:max_activities]: - if not isinstance(item, dict): - continue - ride_id = item.get("rideId") - if ride_id is None: - ride_id = item.get("RideId") - if ride_id is None: - continue - activities.append( - Activity( - ride_id=int(ride_id), - title=item.get("title") or item.get("Title") or f"iGPSPORT {ride_id}", - start_time=( - item.get("startTime") - or item.get("StartTime") - or item.get("startTimeString") - or item.get("StartTimeString") - or "unknown date" - ), - ) + page_no = 1 + + while len(activities) < max_activities: + resp = session.get( + region.activity_query_url, + params={ + "pageNo": str(page_no), + "pageSize": str(page_size), + "sort": "1", + "reqType": "0", + }, ) + resp.raise_for_status() + + body = resp.json() + data = body.get("data") if isinstance(body, dict) else {} + rows = (data.get("rows") if isinstance(data, dict) else None) or [] + if not rows: + break + + for item in rows: + if len(activities) >= max_activities: + break + if not isinstance(item, dict): + continue + ride_id = item.get("rideId") + if ride_id is None: + ride_id = item.get("RideId") + if ride_id is None: + continue + activities.append( + Activity( + ride_id=int(ride_id), + title=item.get("title") or item.get("Title") or f"iGPSPORT {ride_id}", + start_time=( + item.get("startTime") + or item.get("StartTime") + or item.get("startTimeString") + or item.get("StartTimeString") + or "unknown date" + ), + ) + ) + + if len(rows) < page_size: + break + + page_no += 1 + return activities diff --git a/tests/test_igpsport_core.py b/tests/test_igpsport_core.py index aed1b1c..26cd212 100644 --- a/tests/test_igpsport_core.py +++ b/tests/test_igpsport_core.py @@ -225,34 +225,69 @@ def get(self, url, params=None): def test_list_activities_requests_full_page_and_caps(): - """pageSize is sent so we get more than the server's default 10, and the - result is still capped at max_activities as a safety belt.""" - captured = {} + """Paginates when the API caps each page at 20 rows.""" + captured_pages: list[dict] = [] class FakeSession: def get(self, url, params=None): - captured["url"] = url - captured["params"] = params + captured_pages.append(dict(params or {})) + page_no = int(params["pageNo"]) + page_size = int(params["pageSize"]) + start = (page_no - 1) * page_size + end = min(start + page_size, 50) rows = [ { "rideId": i, "title": f"Ride {i}", "startTime": "2026-05-28 19:20:42", } - for i in range(50) + for i in range(start, end) ] return FakeResponse(json_data={"data": {"rows": rows}}) acts = core.list_activities(FakeSession(), 50) - assert "queryMyActivity" in captured["url"] - assert captured["params"] == { + assert len(captured_pages) == 3 + assert captured_pages[0]["pageNo"] == "1" + assert captured_pages[0]["pageSize"] == "20" + assert captured_pages[1]["pageNo"] == "2" + assert captured_pages[2]["pageNo"] == "3" + assert len(acts) == 50 + assert acts[0].ride_id == 0 + assert acts[-1].ride_id == 49 + + +def test_list_activities_paginates_when_api_caps_page_size(): + """Real API returns at most 20 rows per page even when pageSize is larger.""" + captured_pages: list[dict] = [] + + class FakeSession: + def get(self, url, params=None): + captured_pages.append(dict(params or {})) + page_no = int(params["pageNo"]) + # Simulate iGPSPORT: always cap at 20 rows per page. + start = (page_no - 1) * 20 + if start >= 35: + rows = [] + else: + end = min(start + 20, 35) + rows = [ + {"rideId": i, "title": f"Ride {i}", "startTime": "2026-05-28 19:20:42"} + for i in range(start, end) + ] + return FakeResponse(json_data={"data": {"rows": rows}}) + + acts = core.list_activities(FakeSession(), 30) + assert len(captured_pages) == 2 + assert captured_pages[0] == { "pageNo": "1", - "pageSize": "50", + "pageSize": "20", "sort": "1", "reqType": "0", } - assert len(acts) == 50 + assert captured_pages[1]["pageNo"] == "2" + assert len(acts) == 30 assert acts[0].ride_id == 0 + assert acts[-1].ride_id == 29 def test_list_activities_caps_when_server_returns_extra(): From 9827325f3a6d65cbd21d97cc84bf886e5a74066b Mon Sep 17 00:00:00 2001 From: Jorge Silva Date: Mon, 20 Jul 2026 14:54:07 +0100 Subject: [PATCH 2/2] fix(igpsport): parse dotted start times for intervals.icu dedup window Some accounts return date-only YYYY.MM.DD times from iGPSPORT. Unparsed starts triggered a 365-day fallback range, inflating the already-uploaded count from unrelated intervals.icu activities. Co-authored-by: Cursor --- src/intervalssync/igpsport/core.py | 36 ++++++++++++++++++++++-------- src/intervalssync/intervals_icu.py | 6 ++++- tests/test_igpsport_core.py | 22 ++++++++++++++++++ 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/intervalssync/igpsport/core.py b/src/intervalssync/igpsport/core.py index 6e4df3b..525c6a9 100644 --- a/src/intervalssync/igpsport/core.py +++ b/src/intervalssync/igpsport/core.py @@ -37,8 +37,28 @@ LOGIN_URL = INTERNATIONAL.login_url GATEWAY = INTERNATIONAL.gateway_base -# iGPSPORT reports activity start times as "YYYY-MM-DD HH:MM:SS". +# iGPSPORT usually reports start times as "YYYY-MM-DD HH:MM:SS", but some +# accounts/regions return date-only dotted forms like "2026.07.19". IGP_TIME_FORMAT = "%Y-%m-%d %H:%M:%S" +IGP_TIME_FORMATS = ( + IGP_TIME_FORMAT, + "%Y-%m-%d", + "%Y.%m.%d %H:%M:%S", + "%Y.%m.%d", +) + + +def parse_igp_start_time(value: str) -> datetime | None: + """Parse an iGPSPORT activity start time; return None if unrecognised.""" + if not value or not isinstance(value, str): + return None + text = value.strip() + for fmt in IGP_TIME_FORMATS: + try: + return datetime.strptime(text, fmt) + except ValueError: + continue + return None # iGPSPORT exports every ride as the generic "Ride". intervals.icu doesn't take # a sport on upload, so we PUT the desired type afterwards. An empty activity @@ -72,9 +92,8 @@ def dropbox_filename_for(activity: "Activity", use_date: bool = True) -> str: fallback = f"{external_id_for(activity.ride_id)}.fit" if not use_date: return fallback - try: - start = datetime.strptime(activity.start_time, IGP_TIME_FORMAT) - except (TypeError, ValueError): + start = parse_igp_start_time(activity.start_time) + if start is None: return fallback return f"ride-0-{start:%Y-%m-%d-%H-%M-%S}.fit" @@ -302,10 +321,9 @@ def _activity_date_range(activities: list[Activity]) -> tuple[date, date]: """ dates: list[date] = [] for act in activities: - try: - dates.append(datetime.strptime(act.start_time, IGP_TIME_FORMAT).date()) - except (ValueError, TypeError): - continue + start = parse_igp_start_time(act.start_time) + if start is not None: + dates.append(start.date()) if not dates: today = date.today() @@ -380,7 +398,7 @@ def sync(config: SyncConfig, progress: Progress | None = None) -> SyncResult: ) report( f"{len(already_uploaded)} activities already on intervals.icu " - "in this date range." + f"in {oldest.isoformat()}…{newest.isoformat()}." ) except requests.RequestException as exc: report(f"⚠ Could not check intervals.icu (will process all): {exc}") diff --git a/src/intervalssync/intervals_icu.py b/src/intervalssync/intervals_icu.py index d890b1b..5dd7893 100644 --- a/src/intervalssync/intervals_icu.py +++ b/src/intervalssync/intervals_icu.py @@ -99,7 +99,11 @@ def fetch_uploaded_external_ids( auth=("API_KEY", api_key), ) resp.raise_for_status() - return {a["external_id"] for a in resp.json() if a.get("external_id")} + return { + a["external_id"] + for a in resp.json() + if isinstance(a, dict) and a.get("external_id") + } def fetch_calendar_workouts( diff --git a/tests/test_igpsport_core.py b/tests/test_igpsport_core.py index 26cd212..99a433b 100644 --- a/tests/test_igpsport_core.py +++ b/tests/test_igpsport_core.py @@ -62,6 +62,28 @@ def test_activity_date_range_from_start_times(): assert newest == date(2026, 5, 29) # max + 1 day +def test_activity_date_range_parses_dotted_dates(): + """Some iGPSPORT accounts return date-only 'YYYY.MM.DD' start times.""" + acts = [ + core.Activity(1, "a", "2026.07.19"), + core.Activity(2, "b", "2026.05.03"), + ] + oldest, newest = core._activity_date_range(acts) + assert oldest == date(2026, 5, 2) + assert newest == date(2026, 7, 20) + + +def test_parse_igp_start_time_formats(): + assert core.parse_igp_start_time("2026-05-28 19:20:42").date() == date(2026, 5, 28) + assert core.parse_igp_start_time("2026.07.19").date() == date(2026, 7, 19) + assert core.parse_igp_start_time("unknown date") is None + + +def test_dropbox_filename_for_dotted_date(): + act = core.Activity(1, "Ride", "2026.07.19") + assert core.dropbox_filename_for(act) == "ride-0-2026-07-19-00-00-00.fit" + + def test_activity_date_range_fallback_when_unparseable(): acts = [core.Activity(1, "a", "unknown date")] oldest, newest = core._activity_date_range(acts)