feat: retention days limit and clone name templating and useful descr.

This commit is contained in:
2026-07-28 16:01:12 +02:00
parent 9141a09b56
commit 6fab8fe4a5
13 changed files with 273 additions and 23 deletions

View File

@ -9,8 +9,10 @@ GARMIN_PASSWORD=
GARMIN_TOKEN_STORE=.garmin-tokens GARMIN_TOKEN_STORE=.garmin-tokens
GARMIN_TOKEN_DIR=/data/garmin-tokens GARMIN_TOKEN_DIR=/data/garmin-tokens
CLONE_PREFIX=GCClone CLONE_PREFIX=GCClone
CLONE_NAME_TEMPLATE={type} {date} ({clone})
OVERWRITE_EXISTING=true OVERWRITE_EXISTING=true
DELETE_OLD_CLONES=false DELETE_OLD_CLONES=false
CLONE_RETENTION_DAYS=5
CHANGE_DETECTION_INTERVAL_MINUTES=30 CHANGE_DETECTION_INTERVAL_MINUTES=30
CHANGE_DETECTION_ACTIVE_WINDOW=05:00-22:00 CHANGE_DETECTION_ACTIVE_WINDOW=05:00-22:00
CHANGE_DETECTION_FIXED_TIMES=05:15,06:15,07:15,08:15,09:15,10:15,11:15,12:15,13:15 CHANGE_DETECTION_FIXED_TIMES=05:15,06:15,07:15,08:15,09:15,10:15,11:15,12:15,13:15

View File

@ -67,8 +67,10 @@ APP_SECRET_KEY=replace-with-a-long-random-secret
SYNC_ENABLED=true SYNC_ENABLED=true
SYNC_DAYS_AHEAD=1 SYNC_DAYS_AHEAD=1
CLONE_PREFIX=GCClone CLONE_PREFIX=GCClone
CLONE_NAME_TEMPLATE={type} {date} ({clone})
OVERWRITE_EXISTING=true OVERWRITE_EXISTING=true
DELETE_OLD_CLONES=false DELETE_OLD_CLONES=false
CLONE_RETENTION_DAYS=5
CHANGE_DETECTION_INTERVAL_MINUTES=30 CHANGE_DETECTION_INTERVAL_MINUTES=30
CHANGE_DETECTION_ACTIVE_WINDOW=05:00-22:00 CHANGE_DETECTION_ACTIVE_WINDOW=05:00-22:00
CHANGE_DETECTION_FIXED_TIMES=05:15,06:15,07:15,08:15,09:15,10:15,11:15,12:15,13:15 CHANGE_DETECTION_FIXED_TIMES=05:15,06:15,07:15,08:15,09:15,10:15,11:15,12:15,13:15

View File

@ -14,8 +14,10 @@ services:
SYNC_ENABLED: "${SYNC_ENABLED:-true}" SYNC_ENABLED: "${SYNC_ENABLED:-true}"
SYNC_DAYS_AHEAD: "${SYNC_DAYS_AHEAD:-1}" SYNC_DAYS_AHEAD: "${SYNC_DAYS_AHEAD:-1}"
CLONE_PREFIX: "${CLONE_PREFIX:-GCClone}" CLONE_PREFIX: "${CLONE_PREFIX:-GCClone}"
CLONE_NAME_TEMPLATE: "${CLONE_NAME_TEMPLATE:-{type} {date} ({clone})}"
OVERWRITE_EXISTING: "${OVERWRITE_EXISTING:-true}" OVERWRITE_EXISTING: "${OVERWRITE_EXISTING:-true}"
DELETE_OLD_CLONES: "${DELETE_OLD_CLONES:-false}" DELETE_OLD_CLONES: "${DELETE_OLD_CLONES:-false}"
CLONE_RETENTION_DAYS: "${CLONE_RETENTION_DAYS:-5}"
CHANGE_DETECTION_INTERVAL_MINUTES: "${CHANGE_DETECTION_INTERVAL_MINUTES:-30}" CHANGE_DETECTION_INTERVAL_MINUTES: "${CHANGE_DETECTION_INTERVAL_MINUTES:-30}"
CHANGE_DETECTION_ACTIVE_WINDOW: "${CHANGE_DETECTION_ACTIVE_WINDOW:-05:00-22:00}" CHANGE_DETECTION_ACTIVE_WINDOW: "${CHANGE_DETECTION_ACTIVE_WINDOW:-05:00-22:00}"
CHANGE_DETECTION_FIXED_TIMES: "${CHANGE_DETECTION_FIXED_TIMES:-05:15,06:15,07:15,08:15,09:15,10:15,11:15,12:15,13:15}" CHANGE_DETECTION_FIXED_TIMES: "${CHANGE_DETECTION_FIXED_TIMES:-05:15,06:15,07:15,08:15,09:15,10:15,11:15,12:15,13:15}"

View File

@ -249,6 +249,7 @@ def create_app() -> FastAPI:
active_window: str = Form(...), active_window: str = Form(...),
fixed_times: str = Form(...), fixed_times: str = Form(...),
days_ahead: int = Form(...), days_ahead: int = Form(...),
clone_name_template: str = Form(...),
) -> Any: ) -> Any:
config = ScheduleConfig( config = ScheduleConfig(
enabled=enabled == "on", enabled=enabled == "on",
@ -256,6 +257,7 @@ def create_app() -> FastAPI:
active_window=active_window.strip(), active_window=active_window.strip(),
fixed_times=[item.strip() for item in fixed_times.split(",") if item.strip()], fixed_times=[item.strip() for item in fixed_times.split(",") if item.strip()],
days_ahead=days_ahead, days_ahead=days_ahead,
clone_name_template=clone_name_template.strip(),
) )
try: try:
validate_schedule_config(config) validate_schedule_config(config)

View File

@ -17,6 +17,7 @@ DEFAULT_FIXED_TIMES = [
] ]
DEFAULT_ACTIVE_WINDOW = "05:00-22:00" DEFAULT_ACTIVE_WINDOW = "05:00-22:00"
DEFAULT_INTERVAL_MINUTES = 30 DEFAULT_INTERVAL_MINUTES = 30
DEFAULT_CLONE_NAME_TEMPLATE = "{type} {date} ({clone})"
def _project_root() -> Path: def _project_root() -> Path:
@ -37,6 +38,7 @@ class Settings:
app_secret_key: str app_secret_key: str
timezone: str timezone: str
clone_prefix: str clone_prefix: str
clone_name_template: str
sync_enabled: bool sync_enabled: bool
sync_days_ahead: int sync_days_ahead: int
overwrite_existing: bool overwrite_existing: bool
@ -72,6 +74,7 @@ def load_settings() -> Settings:
app_secret_key=os.getenv("APP_SECRET_KEY", "replace-with-long-random-secret"), app_secret_key=os.getenv("APP_SECRET_KEY", "replace-with-long-random-secret"),
timezone=os.getenv("TZ", "Europe/Berlin"), timezone=os.getenv("TZ", "Europe/Berlin"),
clone_prefix=os.getenv("CLONE_PREFIX", "GCClone"), clone_prefix=os.getenv("CLONE_PREFIX", "GCClone"),
clone_name_template=os.getenv("CLONE_NAME_TEMPLATE", DEFAULT_CLONE_NAME_TEMPLATE),
sync_enabled=_bool_env("SYNC_ENABLED", True), sync_enabled=_bool_env("SYNC_ENABLED", True),
sync_days_ahead=max(0, int(os.getenv("SYNC_DAYS_AHEAD", "1"))), sync_days_ahead=max(0, int(os.getenv("SYNC_DAYS_AHEAD", "1"))),
overwrite_existing=_bool_env("OVERWRITE_EXISTING", True), overwrite_existing=_bool_env("OVERWRITE_EXISTING", True),

View File

@ -7,6 +7,7 @@ from typing import Any
from .config import ( from .config import (
DEFAULT_ACTIVE_WINDOW, DEFAULT_ACTIVE_WINDOW,
DEFAULT_CLONE_NAME_TEMPLATE,
DEFAULT_FIXED_TIMES, DEFAULT_FIXED_TIMES,
DEFAULT_INTERVAL_MINUTES, DEFAULT_INTERVAL_MINUTES,
Settings, Settings,
@ -23,6 +24,7 @@ class ScheduleConfig:
active_window: str active_window: str
fixed_times: list[str] fixed_times: list[str]
days_ahead: int days_ahead: int
clone_name_template: str = DEFAULT_CLONE_NAME_TEMPLATE
class Repository: class Repository:
@ -88,6 +90,8 @@ class Repository:
self.get_setting("change_fixed_times"), self.settings.change_fixed_times self.get_setting("change_fixed_times"), self.settings.change_fixed_times
), ),
days_ahead=int(self.get_setting("sync_days_ahead") or self.settings.sync_days_ahead), days_ahead=int(self.get_setting("sync_days_ahead") or self.settings.sync_days_ahead),
clone_name_template=self.get_setting("clone_name_template")
or self.settings.clone_name_template,
) )
def save_schedule_config(self, config: ScheduleConfig) -> None: def save_schedule_config(self, config: ScheduleConfig) -> None:
@ -97,6 +101,7 @@ class Repository:
self.set_setting("change_active_window", config.active_window) self.set_setting("change_active_window", config.active_window)
self.set_setting("change_fixed_times", ",".join(config.fixed_times)) self.set_setting("change_fixed_times", ",".join(config.fixed_times))
self.set_setting("sync_days_ahead", str(config.days_ahead)) self.set_setting("sync_days_ahead", str(config.days_ahead))
self.set_setting("clone_name_template", config.clone_name_template)
def restore_default_schedule(self) -> ScheduleConfig: def restore_default_schedule(self) -> ScheduleConfig:
config = ScheduleConfig( config = ScheduleConfig(
@ -105,6 +110,7 @@ class Repository:
active_window=DEFAULT_ACTIVE_WINDOW, active_window=DEFAULT_ACTIVE_WINDOW,
fixed_times=DEFAULT_FIXED_TIMES.copy(), fixed_times=DEFAULT_FIXED_TIMES.copy(),
days_ahead=self.settings.sync_days_ahead, days_ahead=self.settings.sync_days_ahead,
clone_name_template=self.settings.clone_name_template,
) )
self.save_schedule_config(config) self.save_schedule_config(config)
return config return config
@ -315,6 +321,28 @@ def validate_schedule_config(config: ScheduleConfig) -> None:
raise ValueError(f"invalid fixed time: {value}") raise ValueError(f"invalid fixed time: {value}")
if config.days_ahead < 0 or config.days_ahead > 14: if config.days_ahead < 0 or config.days_ahead > 14:
raise ValueError("days ahead must be between 0 and 14") raise ValueError("days ahead must be between 0 and 14")
_validate_clone_name_template(config.clone_name_template)
def _validate_clone_name_template(value: str) -> None:
if not value.strip():
raise ValueError("clone name template cannot be blank")
allowed = {"type", "date", "clone"}
import string
try:
fields = {
field_name
for _, field_name, _, _ in string.Formatter().parse(value)
if field_name
}
except ValueError as exc:
raise ValueError(f"invalid clone name template: {exc}") from exc
unknown = fields - allowed
if unknown:
raise ValueError("unknown clone name template token(s): " + ", ".join(sorted(unknown)))
if "clone" not in fields:
raise ValueError("clone name template must include {clone}")
def _split_window(value: str) -> tuple[str, str]: def _split_window(value: str) -> tuple[str, str]:

View File

@ -159,7 +159,12 @@ class SyncService:
source_name = str( source_name = str(
source.get("workoutName") or task_workout.get("workoutName") or "Coach Workout" source.get("workoutName") or task_workout.get("workoutName") or "Coach Workout"
) )
payload = clone_workout_payload(source, target_date, self.settings.clone_prefix) payload = clone_workout_payload(
source,
target_date,
self.settings.clone_prefix,
self.repo.schedule_config().clone_name_template,
)
errors = validate_workout_payload(payload) errors = validate_workout_payload(payload)
if errors: if errors:
return self._trace( return self._trace(
@ -187,6 +192,8 @@ class SyncService:
target_date, target_date,
payload, payload,
) )
if current_workout and current_workout.get("workoutName"):
clone_name = str(current_workout["workoutName"])
scheduled_ids = self._find_scheduled_ids(client, clone_name, target_date) scheduled_ids = self._find_scheduled_ids(client, clone_name, target_date)
if current_workout is not None and scheduled_ids: if current_workout is not None and scheduled_ids:
removed = self._repair_current_schedule( removed = self._repair_current_schedule(
@ -223,7 +230,11 @@ class SyncService:
else: else:
action = "create" if mapping is None else "replace_changed" action = "create" if mapping is None else "replace_changed"
existing = existing_clone_workouts( existing = [
workout
for workout in client.get_workouts(limit=100)
if str(workout.get("workoutName") or "") == str(payload["workoutName"])
] or existing_clone_workouts(
client.get_workouts(limit=100), target_date, self.settings.clone_prefix client.get_workouts(limit=100), target_date, self.settings.clone_prefix
) )
if not mapping and existing: if not mapping and existing:
@ -272,7 +283,7 @@ class SyncService:
elif action == "recreate_missing": elif action == "recreate_missing":
for scheduled_id in stale_scheduled_ids: for scheduled_id in stale_scheduled_ids:
client.unschedule_workout(scheduled_id) client.unschedule_workout(scheduled_id)
self._delete_date_marker_workouts(client, target_date) self._delete_date_marker_workouts(client, target_date, str(payload["workoutName"]))
upload_result = client.upload_workout(payload) upload_result = client.upload_workout(payload)
workout_id = upload_result.get("workoutId") or upload_result.get("id") workout_id = upload_result.get("workoutId") or upload_result.get("id")
@ -355,6 +366,25 @@ class SyncService:
target_date: date, target_date: date,
payload: dict[str, Any], payload: dict[str, Any],
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
mapped_id = mapping.get("clone_workout_id")
if mapped_id:
for workout in workouts:
if _workout_id(workout) == str(mapped_id):
detail = self._workout_detail(client, workout)
if detail is not None and workout_steps_equal(detail, payload):
return detail
expected_names = {
str(mapping.get("clone_workout_name") or ""),
str(payload.get("workoutName") or ""),
}
for workout in workouts:
if str(workout.get("workoutName") or "") not in expected_names:
continue
detail = self._workout_detail(client, workout)
if detail is not None and workout_steps_equal(detail, payload):
return detail
# Existing installations used a date-prefixed name. Continue recognizing those
# clones even if the user has since switched to a title template.
for workout in existing_clone_workouts(workouts, target_date, self.settings.clone_prefix): for workout in existing_clone_workouts(workouts, target_date, self.settings.clone_prefix):
detail = self._workout_detail(client, workout) detail = self._workout_detail(client, workout)
if detail is not None and workout_steps_equal(detail, payload): if detail is not None and workout_steps_equal(detail, payload):
@ -389,11 +419,16 @@ class SyncService:
if self.settings.delete_old_clones and mapping.get("clone_workout_id"): if self.settings.delete_old_clones and mapping.get("clone_workout_id"):
client.delete_workout(str(mapping["clone_workout_id"])) client.delete_workout(str(mapping["clone_workout_id"]))
def _delete_date_marker_workouts(self, client: Any, target_date: date) -> list[str]: def _delete_date_marker_workouts(
self, client: Any, target_date: date, expected_name: str
) -> list[str]:
deleted: list[str] = [] deleted: list[str] = []
for workout in existing_clone_workouts( for workout in client.get_workouts(limit=100):
client.get_workouts(limit=100), target_date, self.settings.clone_prefix name = str(workout.get("workoutName") or "")
): if name != expected_name and workout not in existing_clone_workouts(
[workout], target_date, self.settings.clone_prefix
):
continue
workout_id = _workout_id(workout) workout_id = _workout_id(workout)
if workout_id is None: if workout_id is None:
continue continue
@ -405,23 +440,24 @@ class SyncService:
self, client: Any, clone_name: str, target_date: date self, client: Any, clone_name: str, target_date: date
) -> list[str]: ) -> list[str]:
date_s = target_date.isoformat() date_s = target_date.isoformat()
marker = f"{self.settings.clone_prefix} {date_s}"
calendar = client.get_scheduled_workouts(target_date.year, target_date.month) calendar = client.get_scheduled_workouts(target_date.year, target_date.month)
ids: list[str] = [] ids: list[str] = []
for entry in generated_calendar_entries(calendar, self.settings.clone_prefix): for entry in generated_calendar_entries(calendar):
entry_date = calendar_entry_date(entry) entry_date = calendar_entry_date(entry)
if entry_date is not None and entry_date != date_s: if entry_date is not None and entry_date != date_s:
continue continue
name = calendar_entry_name(entry) name = calendar_entry_name(entry)
if ( if (
name is not None name is not None
and name.startswith(marker) and name == clone_name
and calendar_entry_id(entry) is not None and calendar_entry_id(entry) is not None
): ):
ids.append(str(calendar_entry_id(entry))) ids.append(str(calendar_entry_id(entry)))
return ids return ids
def _cleanup_old_clones(self, run_id: int, client: Any) -> dict[str, int]: def _cleanup_old_clones(self, run_id: int, client: Any) -> dict[str, int]:
if not self.settings.delete_old_clones:
return {"warnings": 0}
retention_days = self.settings.clone_retention_days retention_days = self.settings.clone_retention_days
cutoff = _today(self.settings) - timedelta(days=retention_days) cutoff = _today(self.settings) - timedelta(days=retention_days)
warnings = 0 warnings = 0

View File

@ -16,6 +16,8 @@ from garminconnect.workout import (
create_warmup_step, create_warmup_step,
) )
from .config import DEFAULT_CLONE_NAME_TEMPLATE
CYCLING_SPORT = {"sportTypeId": 2, "sportTypeKey": "cycling", "displayOrder": 2} CYCLING_SPORT = {"sportTypeId": 2, "sportTypeKey": "cycling", "displayOrder": 2}
CLONE_ID_FIELDS = { CLONE_ID_FIELDS = {
"author", "author",
@ -66,7 +68,10 @@ def build_dummy_cycling_workout(name: str | None = None) -> dict[str, Any]:
def clone_workout_payload( def clone_workout_payload(
source: dict[str, Any], scheduled_date: date, prefix: str source: dict[str, Any],
scheduled_date: date,
prefix: str,
name_template: str = DEFAULT_CLONE_NAME_TEMPLATE,
) -> dict[str, Any]: ) -> dict[str, Any]:
if "workoutSegments" not in source: if "workoutSegments" not in source:
raise ValueError("source object does not contain workoutSegments") raise ValueError("source object does not contain workoutSegments")
@ -79,11 +84,10 @@ def clone_workout_payload(
or source.get("title") or source.get("title")
or "Garmin Coach Workout" or "Garmin Coach Workout"
) )
payload["workoutName"] = f"{prefix} {scheduled_date.isoformat()} {original_name}"[:120] payload["workoutName"] = clone_workout_name(
payload["description"] = ( original_name, scheduled_date, prefix, name_template
"Generated by garmin-coach-to-cal-sync probe from a Garmin Coach/adaptive "
"workout-like object. Verify targets on the Edge before relying on it."
) )
payload["description"] = clone_workout_description(source)
payload["sportType"] = CYCLING_SPORT payload["sportType"] = CYCLING_SPORT
for idx, segment in enumerate(payload.get("workoutSegments", []), start=1): for idx, segment in enumerate(payload.get("workoutSegments", []), start=1):
if isinstance(segment, dict): if isinstance(segment, dict):
@ -93,6 +97,35 @@ def clone_workout_payload(
return payload return payload
def clone_workout_name(
original_name: str, scheduled_date: date, prefix: str, template: str
) -> str:
rendered_date = f"{scheduled_date.strftime('%B')} {scheduled_date.day}, {scheduled_date.year}"
try:
name = template.format(
type=original_name,
date=rendered_date,
clone=prefix.lower(),
)
except (KeyError, ValueError) as exc:
raise ValueError(f"invalid clone name template: {exc}") from exc
if not name.strip():
raise ValueError("clone name template rendered an empty name")
return name[:120]
def clone_workout_description(source: dict[str, Any]) -> str:
original_description = str(
source.get("description") or source.get("workoutDescription") or ""
).strip()
intervals = _interval_summary(source)
lines = [line for line in (original_description, intervals) if line]
lines.append(
"GCClone — cloned from Garmin Coach; completing it may not update the Coach plan."
)
return "\n".join(lines)
def validate_workout_payload(workout: dict[str, Any]) -> list[str]: def validate_workout_payload(workout: dict[str, Any]) -> list[str]:
errors: list[str] = [] errors: list[str] = []
name = workout.get("workoutName") name = workout.get("workoutName")
@ -218,13 +251,19 @@ def find_generated_workout(
return None return None
def generated_calendar_entries(calendar_data: Any, prefix: str) -> list[dict[str, Any]]: def generated_calendar_entries(
calendar_data: Any, prefix: str | None = None
) -> list[dict[str, Any]]:
entries: list[dict[str, Any]] = [] entries: list[dict[str, Any]] = []
def walk(node: Any) -> None: def walk(node: Any) -> None:
if isinstance(node, dict): if isinstance(node, dict):
name = calendar_entry_name(node) name = calendar_entry_name(node)
if name is not None and name.startswith(prefix) and calendar_entry_id(node) is not None: if (
name is not None
and (prefix is None or name.startswith(prefix))
and calendar_entry_id(node) is not None
):
entries.append(node) entries.append(node)
for child in node.values(): for child in node.values():
walk(child) walk(child)
@ -290,6 +329,94 @@ def summarize_workout(workout: dict[str, Any]) -> str:
return "\n".join(lines) return "\n".join(lines)
def _interval_summary(workout: dict[str, Any]) -> str:
entries: list[str] = []
for segment in workout.get("workoutSegments", []):
if isinstance(segment, dict):
entries.extend(_interval_entries(segment.get("workoutSteps", [])))
return f"Intervals: {' | '.join(entries)}" if entries else ""
def _interval_entries(steps: Any) -> list[str]:
if not isinstance(steps, list):
return []
entries: list[str] = []
for step in steps:
if not isinstance(step, dict):
continue
step_type = _key(step.get("stepType")) or str(step.get("type") or "step")
if step_type == "repeat" or str(step.get("type")) == "RepeatGroupDTO":
nested = _interval_entries(step.get("workoutSteps", []))
if nested:
iterations = _number(step.get("numberOfIterations")) or _number(
step.get("endConditionValue")
)
count = _format_number(iterations) if iterations is not None else "?"
entries.append(f"{count}× ({' | '.join(nested)})")
continue
duration = _step_duration(step)
if duration is None:
continue
label = _step_label(step_type)
target = _step_target(step)
entries.append(f"{label} {duration}" + (f" @ {target}" if target else ""))
return entries
def _step_duration(step: dict[str, Any]) -> str | None:
if _key(step.get("endCondition")) != "time":
return None
seconds = _number(step.get("endConditionValue"))
if seconds is None or seconds < 0:
return None
total = round(seconds)
hours, remainder = divmod(total, 3600)
minutes, secs = divmod(remainder, 60)
return f"{hours}:{minutes:02}:{secs:02}" if hours else f"{minutes}:{secs:02}"
def _step_label(step_type: str) -> str:
labels = {
"warmup": "Warm up",
"cooldown": "Cool down",
"interval": "Interval",
"recovery": "Recover",
"rest": "Rest",
"main": "Main",
"other": "Other",
}
return labels.get(step_type, step_type.replace("_", " ").title())
def _step_target(step: dict[str, Any]) -> str:
target_type = _key(step.get("targetType")) or ""
one = _number(step.get("targetValueOne"))
two = _number(step.get("targetValueTwo"))
if target_type in {"", "no.target"}:
return ""
if target_type == "instruction":
return str(step.get("description") or "instruction")
unit = str(step.get("targetValueUnit") or "").strip()
if not unit:
if "power" in target_type:
unit = "W"
elif "heart.rate" in target_type:
unit = "bpm"
elif "cadence" in target_type:
unit = "rpm"
values = [value for value in (one, two) if value is not None]
if not values:
return target_type.replace(".", " ")
value_text = "".join(_format_number(value) for value in values)
return f"{value_text} {unit}".rstrip()
def _format_number(value: float | None) -> str:
if value is None:
return "?"
return str(int(value)) if value.is_integer() else f"{value:g}"
def _summarize_steps(lines: list[str], steps: Any, indent: str) -> None: def _summarize_steps(lines: list[str], steps: Any, indent: str) -> None:
if not isinstance(steps, list): if not isinstance(steps, list):
return return

View File

@ -36,6 +36,13 @@
<label>Fixed check times <label>Fixed check times
<textarea name="fixed_times" rows="3" required>{{ schedule.fixed_times | join(",") }}</textarea> <textarea name="fixed_times" rows="3" required>{{ schedule.fixed_times | join(",") }}</textarea>
</label> </label>
<fieldset>
<legend>Clone presentation</legend>
<label>Clone name template
<input name="clone_name_template" value="{{ schedule.clone_name_template }}" required>
</label>
<p class="muted">Use <code>{type}</code>, <code>{date}</code>, and <code>{clone}</code>. The template must include <code>{clone}</code> so generated workouts remain identifiable.</p>
</fieldset>
<button type="submit">Save schedule</button> <button type="submit">Save schedule</button>
</form> </form>
</section> </section>

View File

@ -52,5 +52,4 @@ def test_analyze_dump_finds_and_clones_fixture(tmp_path: Path) -> None:
assert result.returncode == 0 assert result.returncode == 0
assert "Local clone payload passed validation." in result.stdout assert "Local clone payload passed validation." in result.stdout
assert "GCClone 2026-06-16 Coach Ride" in result.stdout assert "Coach Ride June 16, 2026 (gcclone)" in result.stdout

View File

@ -2,7 +2,11 @@ from __future__ import annotations
import pytest import pytest
from garmin_coach_clone.config import DEFAULT_FIXED_TIMES, load_settings from garmin_coach_clone.config import (
DEFAULT_CLONE_NAME_TEMPLATE,
DEFAULT_FIXED_TIMES,
load_settings,
)
from garmin_coach_clone.db import Database from garmin_coach_clone.db import Database
from garmin_coach_clone.repository import Repository, ScheduleConfig, validate_schedule_config from garmin_coach_clone.repository import Repository, ScheduleConfig, validate_schedule_config
@ -17,6 +21,7 @@ def test_schedule_restore_defaults(tmp_path, monkeypatch) -> None:
restored = repo.restore_default_schedule() restored = repo.restore_default_schedule()
assert restored.fixed_times == DEFAULT_FIXED_TIMES assert restored.fixed_times == DEFAULT_FIXED_TIMES
assert restored.clone_name_template == DEFAULT_CLONE_NAME_TEMPLATE
assert repo.schedule_config().active_window == "05:00-22:00" assert repo.schedule_config().active_window == "05:00-22:00"
@ -31,3 +36,18 @@ def test_schedule_validation_rejects_bad_window() -> None:
days_ahead=1, days_ahead=1,
) )
) )
@pytest.mark.parametrize("template", ["", "{type} {date}", "{unknown} ({clone})"])
def test_schedule_validation_rejects_invalid_clone_name_template(template: str) -> None:
with pytest.raises(ValueError, match="clone name template|unknown clone"):
validate_schedule_config(
ScheduleConfig(
enabled=True,
interval_minutes=30,
active_window="05:00-22:00",
fixed_times=["05:15"],
days_ahead=1,
clone_name_template=template,
)
)

View File

@ -397,6 +397,7 @@ def test_replace_changed_unschedules_matching_calendar_entries(
def test_sync_deletes_generated_clones_older_than_retention(tmp_path, monkeypatch) -> None: def test_sync_deletes_generated_clones_older_than_retention(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(sync_service, "_today", lambda settings: date(2026, 6, 16)) monkeypatch.setattr(sync_service, "_today", lambda settings: date(2026, 6, 16))
monkeypatch.setenv("DELETE_OLD_CLONES", "true")
source = _source("Sprint") source = _source("Sprint")
service, _repo, garmin = _service(tmp_path, monkeypatch, source) service, _repo, garmin = _service(tmp_path, monkeypatch, source)
old_name = "GCClone 2026-06-10 Sprint" old_name = "GCClone 2026-06-10 Sprint"

View File

@ -7,15 +7,17 @@ from garmin_coach_clone.workouts import (
calendar_entry_date, calendar_entry_date,
calendar_entry_id, calendar_entry_id,
calendar_entry_name, calendar_entry_name,
clone_workout_description,
clone_workout_name,
clone_workout_payload, clone_workout_payload,
estimate_duration, estimate_duration,
existing_clone_names, existing_clone_names,
find_generated_calendar_entry, find_generated_calendar_entry,
find_generated_workout, find_generated_workout,
generated_clone_date,
generated_calendar_entries, generated_calendar_entries,
generated_workouts_older_than, generated_clone_date,
generated_workouts, generated_workouts,
generated_workouts_older_than,
validate_workout_payload, validate_workout_payload,
workout_steps_equal, workout_steps_equal,
) )
@ -38,13 +40,32 @@ def test_clone_payload_strips_ids_and_sets_prefix() -> None:
cloned = clone_workout_payload(source, date(2026, 6, 16), "GCClone") cloned = clone_workout_payload(source, date(2026, 6, 16), "GCClone")
assert cloned["workoutName"] == "GCClone 2026-06-16 Coach Original" assert cloned["workoutName"] == "Coach Original June 16, 2026 (gcclone)"
assert "workoutId" not in cloned assert "workoutId" not in cloned
assert "ownerId" not in cloned assert "ownerId" not in cloned
assert "stepId" not in cloned["workoutSegments"][0]["workoutSteps"][0] assert "stepId" not in cloned["workoutSegments"][0]["workoutSteps"][0]
assert validate_workout_payload(cloned) == [] assert validate_workout_payload(cloned) == []
def test_clone_title_and_description_use_source_details() -> None:
source = build_dummy_cycling_workout("Tempo")
source["description"] = "3x10:00@180W"
steps = source["workoutSegments"][0]["workoutSteps"]
steps[0]["targetType"] = {"workoutTargetTypeKey": "power.zone"}
steps[0]["targetValueOne"] = 98
steps[0]["targetValueTwo"] = 142
assert clone_workout_name(
"Tempo", date(2026, 7, 15), "GCClone", "{type} {date} ({clone})"
) == "Tempo July 15, 2026 (gcclone)"
assert clone_workout_description(source) == (
"3x10:00@180W\n"
"Intervals: Warm up 5:00 @ 98142 W | 1× (Interval 2:00 | Recover 2:00) | "
"Cool down 5:00\n"
"GCClone — cloned from Garmin Coach; completing it may not update the Coach plan."
)
def test_workout_steps_equal_ignores_ids_but_compares_steps() -> None: def test_workout_steps_equal_ignores_ids_but_compares_steps() -> None:
left = build_dummy_cycling_workout("Left") left = build_dummy_cycling_workout("Left")
right = build_dummy_cycling_workout("Right") right = build_dummy_cycling_workout("Right")