feat: smart recreate and dedupe

This commit is contained in:
2026-06-16 19:36:12 +02:00
parent 2e25e7eb34
commit 9141a09b56
4 changed files with 100 additions and 3 deletions

View File

@ -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]:

View File

@ -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]]:

View File

@ -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"

View File

@ -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(
[