feat: retention days limit and clone name templating and useful descr.
This commit is contained in:
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}"
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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]:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 @ 98–142 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")
|
||||||
|
|||||||
Reference in New Issue
Block a user