diff --git a/src/garmin_coach_clone/sync_service.py b/src/garmin_coach_clone/sync_service.py index 297a854..3e807d8 100644 --- a/src/garmin_coach_clone/sync_service.py +++ b/src/garmin_coach_clone/sync_service.py @@ -272,6 +272,7 @@ class SyncService: elif action == "recreate_missing": for scheduled_id in stale_scheduled_ids: client.unschedule_workout(scheduled_id) + self._delete_date_marker_workouts(client, target_date) upload_result = client.upload_workout(payload) workout_id = upload_result.get("workoutId") or upload_result.get("id") @@ -388,6 +389,18 @@ class SyncService: if self.settings.delete_old_clones and mapping.get("clone_workout_id"): client.delete_workout(str(mapping["clone_workout_id"])) + def _delete_date_marker_workouts(self, client: Any, target_date: date) -> list[str]: + deleted: list[str] = [] + for workout in existing_clone_workouts( + client.get_workouts(limit=100), target_date, self.settings.clone_prefix + ): + workout_id = _workout_id(workout) + if workout_id is None: + continue + client.delete_workout(workout_id) + deleted.append(workout_id) + return deleted + def _find_scheduled_ids( self, client: Any, clone_name: str, target_date: date ) -> list[str]: diff --git a/src/garmin_coach_clone/workouts.py b/src/garmin_coach_clone/workouts.py index a140134..02dc256 100644 --- a/src/garmin_coach_clone/workouts.py +++ b/src/garmin_coach_clone/workouts.py @@ -382,9 +382,53 @@ def _strip_ids(value: Any) -> None: def _normalized_steps(workout: dict[str, Any]) -> Any: - value = deepcopy(workout.get("workoutSegments")) - _strip_ids(value) - return value + segments = workout.get("workoutSegments") + if not isinstance(segments, list): + return None + return [ + _step_list_signature(segment.get("workoutSteps")) + for segment in segments + if isinstance(segment, dict) + ] + + +def _step_list_signature(steps: Any) -> list[Any]: + if not isinstance(steps, list): + return [] + return [_step_signature(step) for step in steps if isinstance(step, dict)] + + +def _step_signature(step: dict[str, Any]) -> dict[str, Any]: + step_type = _key(step.get("stepType")) or str(step.get("type") or "") + signature: dict[str, Any] = { + "step_type": step_type, + "end_condition": _key(step.get("endCondition")), + "end_value": _number(step.get("endConditionValue")), + } + if str(step.get("type")) == "RepeatGroupDTO" or step_type == "repeat": + signature["iterations"] = _number( + step.get("numberOfIterations") or step.get("endConditionValue") + ) + signature["steps"] = _step_list_signature(step.get("workoutSteps")) + return signature + + signature.update( + { + "target_type": _key(step.get("targetType")), + "target_one": _number(step.get("targetValueOne")), + "target_two": _number(step.get("targetValueTwo")), + "target_unit": step.get("targetValueUnit"), + "secondary_target_type": _key(step.get("secondaryTargetType")), + "secondary_target_one": _number(step.get("secondaryTargetValueOne")), + "secondary_target_two": _number(step.get("secondaryTargetValueTwo")), + "secondary_target_unit": step.get("secondaryTargetValueUnit"), + } + ) + return signature + + +def _number(value: Any) -> Any: + return float(value) if isinstance(value, int | float) else value def _dedupe_calendar_entries(items: list[dict[str, Any]]) -> list[dict[str, Any]]: diff --git a/tests/test_sync_service.py b/tests/test_sync_service.py index 9b14e09..12d44e4 100644 --- a/tests/test_sync_service.py +++ b/tests/test_sync_service.py @@ -216,6 +216,7 @@ def test_sync_recreates_date_name_match_with_different_steps(tmp_path, monkeypat assert result["created"] == 1 assert garmin.client.unscheduled == ["s1"] + assert garmin.client.deleted == ["w-old"] assert garmin.client.scheduled == [("w1", "2026-06-16")] @@ -295,6 +296,7 @@ def test_current_mapping_recreates_when_calendar_entry_is_missing( assert garmin.client.uploads assert garmin.client.scheduled == [("w1", "2026-06-16")] assert garmin.client.unscheduled == [] + assert garmin.client.deleted == ["w-old"] mapping = repo.get_clone_mapping("2026-06-16") assert mapping is not None assert mapping["clone_workout_id"] == "w1" diff --git a/tests/test_workouts.py b/tests/test_workouts.py index 2794378..825a68c 100644 --- a/tests/test_workouts.py +++ b/tests/test_workouts.py @@ -60,6 +60,44 @@ def test_workout_steps_equal_ignores_ids_but_compares_steps() -> None: assert not workout_steps_equal(left, right) +def test_workout_steps_equal_ignores_garmin_upload_defaults() -> None: + left = build_dummy_cycling_workout("Left") + right = build_dummy_cycling_workout("Right") + repeat = right["workoutSegments"][0]["workoutSteps"][1] + repeat["stepOrder"] = 2 + repeat["childStepId"] = 1 + repeat["smartRepeat"] = False + for idx, step in enumerate(right["workoutSegments"][0]["workoutSteps"], start=1): + step["stepId"] = 1000 + idx + step["stepOrder"] = idx + step["equipmentType"] = { + "displayOrder": 0, + "equipmentTypeId": 0, + "equipmentTypeKey": None, + } + step["strokeType"] = { + "displayOrder": 0, + "strokeTypeId": 0, + "strokeTypeKey": None, + } + for idx, step in enumerate(repeat["workoutSteps"], start=3): + step["childStepId"] = 1 + step["stepId"] = 1000 + idx + step["stepOrder"] = idx + step["equipmentType"] = { + "displayOrder": 0, + "equipmentTypeId": 0, + "equipmentTypeKey": None, + } + step["strokeType"] = { + "displayOrder": 0, + "strokeTypeId": 0, + "strokeTypeKey": None, + } + + assert workout_steps_equal(left, right) + + def test_existing_clone_names_filters_by_prefix_and_date() -> None: names = existing_clone_names( [