INITIAL COMMIT

This commit is contained in:
2026-06-16 15:14:37 +02:00
commit 1477ec36fd
49 changed files with 6835 additions and 0 deletions

24
.env.example Normal file
View File

@ -0,0 +1,24 @@
TZ=Europe/Berlin
APP_USERNAME=admin
APP_PASSWORD=change-me
APP_SECRET_KEY=replace-with-a-long-random-secret
SYNC_ENABLED=true
SYNC_DAYS_AHEAD=1
GARMIN_EMAIL=
GARMIN_PASSWORD=
GARMIN_TOKEN_STORE=.garmin-tokens
GARMIN_TOKEN_DIR=/data/garmin-tokens
CLONE_PREFIX=GCClone
OVERWRITE_EXISTING=true
DELETE_OLD_CLONES=false
CHANGE_DETECTION_INTERVAL_MINUTES=30
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
LOG_LEVEL=INFO
DEBUG_DUMP_JSON=false
# Optional, only needed when debugging Garmin browser-only /gc-api endpoints.
# Copy the Cookie request header from your logged-in browser's network request.
# Treat this like a password; never commit it.
GARMIN_GC_API_COOKIE=
GARMIN_GC_API_CSRF=

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/
.ty/
.env
.garmin-tokens/
debug/
dist/
*.egg-info/

26
Dockerfile Normal file
View File

@ -0,0 +1,26 @@
# syntax=docker/dockerfile:1
FROM ghcr.io/astral-sh/uv:0.11.16 AS uv-bin
FROM python:3.12-slim AS runtime
WORKDIR /app
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/app/src \
DATA_DIR=/data \
PATH="/app/.venv/bin:$PATH"
COPY --from=uv-bin /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock* ./
RUN if [ -f uv.lock ]; then uv sync --frozen --no-dev; else uv sync --no-dev; fi
COPY src/ ./src/
COPY templates/ ./templates/
COPY static/ ./static/
VOLUME ["/data"]
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3)"
CMD ["uvicorn", "garmin_coach_clone.app:app", "--host", "0.0.0.0", "--port", "8000"]

133
README.md Normal file
View File

@ -0,0 +1,133 @@
# Garmin Coach to Calendar Sync
This tool clones Garmin Coach/adaptive cycling workouts into normal Garmin Connect cycling workouts and schedules the generated clone on your Garmin calendar. The target use case is an original Garmin Edge 1030: the Edge can sync and run the generated structured workout like a normal workout while still using normal Edge navigation.
It uses unofficial Garmin Connect APIs. Run it locally or on a private network unless you harden it yourself.
## Current Status
Implemented:
- Feasibility research in [docs/feasibility.md](/home/yannik/repos/garmin-coach-to-cal-sync/docs/feasibility.md).
- CLI probe scripts for Garmin login, discovery, Coach extraction, dummy upload/schedule, and clone dry-run.
- Dockerized FastAPI web app with first-launch local login setup, Garmin login/MFA, manual sync, scheduled change detection, logs, traces, search, and schedule editing.
- SQLite state under `/data/app.db`.
- Persistent Garmin tokens under `/data/garmin-tokens`.
- Coolify-compatible `Dockerfile` and `docker-compose.yml`.
Still fragile:
- Garmin can change private API endpoints or authentication behavior.
- Garmin Coach completion may not be marked when you complete the generated clone.
- Edge 1030 acceptance must be verified on your device after upload/schedule.
- FIT export fallback is not implemented because Garmin Connect scheduling is the primary path.
## Quick Start
```sh
cp .env.example .env
docker compose up --build
```
Open [http://localhost:8080](http://localhost:8080).
First launch:
1. Create the local app username/password.
2. Open `Garmin`.
3. Enter Garmin credentials.
4. Enter MFA if Garmin asks for it.
5. Use `Search` to inspect Coach/calendar/workout visibility.
6. Use `Dashboard > Dry-run today` first, then `Check today` or `Check tomorrow`.
## Coolify Deployment
Deploy from the repository link.
Set:
- Container port: `8000`
- Persistent storage mount: `/data`
- `APP_SECRET_KEY`: a long random secret
- `TZ`: `Europe/Berlin`
- Optional bootstrap `APP_USERNAME` / `APP_PASSWORD`
The compose file uses a named volume for local Docker. In Coolify, configure persistent storage for `/data` instead of a repo bind mount. Runtime state, Garmin tokens, SQLite, logs, and traces all live under `/data`.
## Environment
Important defaults are in [.env.example](/home/yannik/repos/garmin-coach-to-cal-sync/.env.example):
```text
TZ=Europe/Berlin
DATA_DIR=/data
APP_USERNAME=admin
APP_PASSWORD=change-me
APP_SECRET_KEY=replace-with-a-long-random-secret
SYNC_ENABLED=true
SYNC_DAYS_AHEAD=1
CLONE_PREFIX=GCClone
OVERWRITE_EXISTING=true
DELETE_OLD_CLONES=false
CHANGE_DETECTION_INTERVAL_MINUTES=30
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
```
Scheduled runs are change-detection runs. They create a missing clone, skip an unchanged clone, and replace only when the Coach source hash changes. If a Coach day becomes rest/empty while a generated clone exists, scheduled sync records a warning and leaves the clone alone.
## Web UI
- `Dashboard`: Garmin status, last run, next run, clone state, manual sync actions.
- `Garmin`: store encrypted Garmin credentials and complete MFA.
- `Search`: list calendar entries, normal workouts, plans, Coach tasks, and generated clones.
- `Schedule`: edit sync enabled, interval window, fixed `:15` checks, days ahead, or restore defaults.
- `Traces`: inspect source Coach task/detail, generated payload, source hash, and upload/schedule result.
- `Logs`: sync events and warnings.
Passwords, MFA codes, cookies, headers, and tokens are not logged. Trace JSON is redacted before storage.
## CLI Probe Commands
The CLI remains useful for low-level debugging:
```sh
uv run python scripts/probe_garmin.py login
uv run python scripts/probe_garmin.py report --dump-json
uv run python scripts/probe_garmin.py coach --date today --dump-json
uv run python scripts/clone_today_workout.py --date today --dry-run --dump-json
uv run python scripts/clone_today_workout.py --date today --schedule
```
Run local verification:
```sh
uv run ruff check .
uv run ty check
uv run pytest
```
## Edge 1030 Testing
After a web or CLI sync creates a clone:
1. Sync the Edge 1030.
2. Check `Training > Workouts`.
3. Check `Training > Training Plan > calendar icon > scheduled date`.
4. Start a route/course.
5. Start the generated workout and verify prompts run while navigation remains active.
More detail is in [docs/edge1030-testing.md](/home/yannik/repos/garmin-coach-to-cal-sync/docs/edge1030-testing.md).
## Cleanup
Generated workouts use the `GCClone YYYY-MM-DD ...` prefix by default. Scheduled automation never deletes a current clone just because a check ran. Replacement only happens when the source hash changes and the stored mapping points to a generated clone.
Use the CLI guarded cleanup helpers if you need manual cleanup:
```sh
uv run python scripts/probe_garmin.py generated --prefix GCClone
uv run python scripts/probe_garmin.py generated-calendar --date today --prefix GCClone
```
Delete or unschedule only IDs shown by those generated-prefix listings.

32
docker-compose.yml Normal file
View File

@ -0,0 +1,32 @@
services:
garmin-coach-clone:
build: .
container_name: garmin-coach-clone
ports:
- "8080:8000"
volumes:
- garmin-coach-clone-data:/data
environment:
TZ: "${TZ:-Europe/Berlin}"
APP_USERNAME: "${APP_USERNAME:-admin}"
APP_PASSWORD: "${APP_PASSWORD:-change-me}"
APP_SECRET_KEY: "${APP_SECRET_KEY:-replace-with-a-long-random-secret}"
SYNC_ENABLED: "${SYNC_ENABLED:-true}"
SYNC_DAYS_AHEAD: "${SYNC_DAYS_AHEAD:-1}"
CLONE_PREFIX: "${CLONE_PREFIX:-GCClone}"
OVERWRITE_EXISTING: "${OVERWRITE_EXISTING:-true}"
DELETE_OLD_CLONES: "${DELETE_OLD_CLONES:-false}"
CHANGE_DETECTION_INTERVAL_MINUTES: "${CHANGE_DETECTION_INTERVAL_MINUTES:-30}"
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}"
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
DEBUG_DUMP_JSON: "${DEBUG_DUMP_JSON:-false}"
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3)"]
interval: 30s
timeout: 5s
retries: 3
volumes:
garmin-coach-clone-data:

62
docs/edge1030-testing.md Normal file
View File

@ -0,0 +1,62 @@
# Edge 1030 Testing Checklist
Use this after `probe_garmin.py dummy --schedule` or a successful clone upload.
## Sync
1. Open Garmin Connect on the phone or Garmin Express on the computer.
2. Sync the Edge 1030.
3. Wait for sync completion before checking menus.
## Check Workout Library
On the Edge 1030:
```text
Training > Workouts
```
Expected:
- The dummy workout or `GCClone YYYY-MM-DD ...` workout appears.
- It is listed as a cycling workout.
- Opening it shows structured steps.
## Check Scheduled Calendar
On the Edge 1030:
```text
Training > Training Plan > calendar icon > scheduled date
```
Expected:
- The scheduled dummy/clone workout appears on the selected date.
- It can be selected from the calendar entry.
## Check Navigation Plus Workout
1. Select a course or route as usual.
2. Start navigation.
3. Start the scheduled workout.
4. Start the ride timer.
Expected:
- Route/navigation remains usable.
- Structured workout prompts appear.
- Step transitions and alerts work.
This is the key test because Extended Display is not acceptable for this project.
## Report Back
Report:
- Whether the workout appeared in `Training > Workouts`.
- Whether it appeared in the calendar date view.
- Whether it could be started.
- Whether navigation continued while the workout ran.
- Whether Garmin Coach marked anything complete after the activity uploaded.

194
docs/feasibility.md Normal file
View File

@ -0,0 +1,194 @@
# Garmin Coach to Edge 1030 Feasibility
Research date: 2026-06-16.
## Executive Summary
The most realistic path is an unofficial Garmin Connect API probe:
1. Read the user's Garmin Connect calendar/training-plan data.
2. Determine whether Garmin Coach cycling workout steps are exposed in the private training-plan/adaptive-plan JSON.
3. If step details are present, create a normal Garmin Connect cycling workout and schedule it.
4. Let the Edge 1030 sync that normal scheduled workout.
What is proven from source documentation/code:
- The Edge 1030 supports normal Garmin Connect structured workouts and scheduled workouts.
- `python-garminconnect` has methods for listing normal workouts, uploading a workout JSON, scheduling/unscheduling workouts, deleting workouts, listing calendar entries, and fetching training/adaptive plan details.
- The Garmin Connect workout JSON schema for normal workouts is partially represented by `python-garminconnect.workout`.
What is proven from this user's account probe:
- Garmin login/token reuse works with `python-garminconnect`.
- The account has an active `FBT_ADAPTIVE` Garmin Cycling Coach plan.
- The plan/calendar APIs expose dated Coach workout summaries, including name, date, sport type, duration, compact description, training effect label, and `workoutUuid`.
- The Garmin Connect browser uses `/gc-api/workout-service/fbt-adaptive/{workoutUuid}` to fetch the full Coach workout with `workoutSegments` and `workoutSteps`.
- The same detail is available to `python-garminconnect` through the Connect API path `/workout-service/fbt-adaptive/{workoutUuid}` without the browser `/gc-api` prefix.
What is still not proven:
- Whether the Edge 1030 accepts every cloned target type after sync.
- Whether the endpoint remains stable as Garmin changes private APIs.
- Whether a summary-derived approximation is good enough for the user's training use case.
The official Garmin Developer Training API is not a good fit for this use case. It is business/approval-gated, designed to publish third-party workouts/plans to users, and does not provide a documented way to read a personal Garmin Coach/adaptive plan workout from Garmin Connect.
## Feasibility Matrix
| Feature | Supported / Unsupported / Unknown | Tested / documented / guessed | API/package/source | Notes |
|---|---:|---:|---|---|
| Edge 1030 runs normal structured workouts from Garmin Connect | Supported | Officially documented; not tested here | Edge 1030 manual: Workouts, Starting a Workout | Manual says workouts can be created in Garmin Connect, transferred, scheduled, and run on device. |
| Edge 1030 shows scheduled workouts in calendar/training plan | Supported | Officially documented; not tested here | Edge 1030 manual: Training Calendar | Menu path documented as `Training > Training Plan > calendar icon`. |
| Official API can read Garmin Coach/adaptive workouts from personal account | Unsupported | Documented absence/inferred from API direction | Garmin Training API, Health API docs | Training API publishes workouts/plans to Garmin Connect; Health API reads health/activity metrics, not Coach workout definitions. |
| Official API can create/schedule workouts | Supported with approval | Officially documented | Garmin Training API | It can publish workouts/training plans to the Garmin Connect calendar. |
| Official API available for personal app | Unsupported for this use case | Officially documented | Garmin Developer Program FAQ | Program is for enterprise/business use and requires approval. |
| Authenticate unofficially with Garmin Connect | Supported/fragile | Tested with user's account | `python-garminconnect` login/tokenstore source | Login worked and wrote `.garmin-tokens`. Future Garmin SSO changes remain a risk. |
| `garth` as standalone auth library | Unsupported/fragile | Project README | `matin/garth` README | `garth` says it is deprecated and new logins may not work after Garmin auth changes. Prefer current `python-garminconnect` probe. |
| Store/reuse Garmin tokens | Supported in source | Source-supported; untested here | `python-garminconnect` login and demo | Token directory can be loaded/dumped. Probe defaults to `.garmin-tokens`. |
| List training plans | Supported | Tested with user's account | `Garmin.get_training_plans()` | Returned one `FBT_ADAPTIVE` plan: `2026 Fitness Plan test`. |
| Detect active Garmin Coach/adaptive plan | Supported/partial | Tested with user's account | `trainingPlanCategory == FBT_ADAPTIVE` | The active cycling Coach plan is detectable. |
| Fetch adaptive training-plan detail | Supported summary only | Tested with user's account | `get_adaptive_training_plan_by_id()` | Returns `taskList` with dated `taskWorkout` summaries, but no step arrays. |
| Fetch Coach workout step details for date | Supported/fragile private API | Tested with user's account | `/workout-service/fbt-adaptive/{workoutUuid}` | Returns full `workoutSegments`/`workoutSteps` via normal `python-garminconnect` token auth. Browser shows equivalent `/gc-api/workout-service/fbt-adaptive/{workoutUuid}`. |
| Read compact Coach workout summary | Supported | Tested with user's account | Adaptive plan `taskList[].taskWorkout` | Exposes fields such as `workoutName`, `estimatedDurationInSecs`, `workoutDescription`, `trainingEffectLabel`, `workoutPhrase`, `workoutUuid`. |
| List Garmin calendar entries | Supported | Tested with user's account | `get_scheduled_workouts(year, month)` | Calendar contains `fbtAdaptiveWorkout` entries with `workoutUuid` and `trainingPlanId`. |
| List normal workouts | Supported | Tested with user's account | `get_workouts()` | Returned normal cycling workouts. |
| Fetch normal workout by ID | Supported in source | Source-supported; untested here | `get_workout_by_id()` | Useful for schema inspection and clone comparisons. |
| Create/upload normal cycling workout | Supported in source | Source-supported; untested here | `upload_workout()`, `upload_cycling_workout()` | Probe includes a minimal dummy cycling workout upload test. |
| Schedule normal workout on date | Supported in source | Source-supported; untested here | `schedule_workout(workout_id, YYYY-MM-DD)` | This is the preferred path if upload works. |
| Delete generated workout | Supported in source | Implemented with guard; not account-tested | `delete_workout()` | Probe refuses to delete unless the ID is in the generated-prefix workout list and `--confirm` is passed. |
| Unschedule generated workout | Supported in source | Implemented with guard; not account-tested | `unschedule_workout()` | Probe refuses to unschedule unless the scheduled ID is in the generated-prefix calendar list and `--confirm` is passed. |
| Avoid duplicate clones | Partially supported | Implemented by name prefix/date | Probe code | Uses `GCClone YYYY-MM-DD` prefix. Full app should persist mappings in SQLite. |
| Generate local FIT workout file | Possible | Official SDK documented; not implemented in probe | Garmin FIT Python SDK | SDK has an encoder. Need workout-file-specific implementation and device testing. |
| Copy `.fit` to `GARMIN/NewFiles` | Unknown | Common Garmin behavior; not verified here | Not accepted as primary source | Treat only as manual fallback until tested on Edge 1030. |
| Use `GARMIN/Workouts/Schedule` for date scheduling | Unknown | Not proven | No reliable official source found | Garmin Connect calendar scheduling is safer. |
| Coach clone marks Garmin Coach workout complete | Unknown/likely unsupported | Guessed | Separate cloned workout | A clone is a normal workout, not the original Coach workout. Completion linkage may not happen. |
## Official Garmin API Findings
Garmin's official Training API is aimed at approved integrations that publish workouts and training plans into Garmin Connect. The Training API page says it can publish workouts/training plans to the Garmin Connect calendar, then users sync compatible devices.
This is directionally opposite of the required first step: reading a personal Garmin Coach/adaptive workout from Garmin Connect. I found no official endpoint for reading Garmin Coach plan workout definitions from a user's account.
The Developer Program FAQ states the Garmin Connect Developer Program is for enterprise/business use and applications are reviewed. That makes it unrealistic for a local personal tool unless the user already controls an approved business integration.
## Unofficial Garmin Connect Findings
The current `python-garminconnect` source exposes the important private endpoints:
- Workouts: `/workout-service`
- Calendar: `/calendar-service`
- Training plans: `/trainingplan-service/trainingplan`
Relevant methods in source:
- `get_workouts(start=0, limit=100)`
- `get_workout_by_id(workout_id)`
- `upload_workout(workout_json)`
- `upload_cycling_workout(workout)`
- `schedule_workout(workout_id, date_str)`
- `get_scheduled_workouts(year, month)`
- `get_scheduled_workout_by_id(scheduled_workout_id)`
- `delete_workout(workout_id)`
- `unschedule_workout(scheduled_workout_id)`
- `get_training_plans()`
- `get_training_plan_by_id(plan_id)`
- `get_adaptive_training_plan_by_id(plan_id)`
The package demo routes plans with `trainingPlanCategory == "FBT_ADAPTIVE"` to the adaptive endpoint. That is the strongest lead for Garmin Coach/adaptive plan extraction, but it does not prove that cycling Coach step details are present for this user's account.
Authentication is a risk. The current `python-garminconnect` package has token storage and MFA support, while the older `garth` project now says new logins may not work because Garmin changed auth. The probe therefore treats login itself as a testable capability.
The probe also includes a read-only metadata call to `/workout-service/workout/types` and a normal-workout-by-ID dump. Those are intended to compare Garmin's current accepted workout schema against any Coach/adaptive JSON found during account testing.
For account testing, `probe_garmin.py report --dump-json` runs the read-only discovery checks in one pass and writes `debug/probe_report.json`. That report is the most compact evidence bundle for deciding whether the proof-of-concept can continue to clone testing.
### Account Probe Evidence
The user's June 16, 2026 probe found an active Garmin Cycling Coach adaptive plan:
- Plan ID: `45692639`
- Category: `FBT_ADAPTIVE`
- Name: `2026 Fitness Plan test`
- Today's Coach workout: `Sprint`
- Workout UUID: `fe26ad37-69e8-495b-9d3d-2ab10fb64334`
- Estimated duration: 3120 seconds
- Compact description: `2x13x0:10@All Out`
The adaptive plan detail endpoint returned `taskList[].taskWorkout` summaries, not `workoutSegments` or `workoutSteps`. The calendar endpoint returned `itemType=fbtAdaptiveWorkout` entries with matching `trainingPlanId` and `workoutUuid`.
The missing endpoint was found in the browser network analyzer:
```text
/gc-api/workout-service/fbt-adaptive/{workoutUuid}
```
For the June 16 `Sprint` workout, that response contains `workoutSegments` and nested repeat groups. Testing showed that the equivalent Connect API route also works:
```text
/workout-service/fbt-adaptive/{workoutUuid}
```
The CLI now uses that Connect API route when a `workoutUuid` is available. The browser `/gc-api` route returned `403` with the package token session, so it remains a browser-specific diagnostic path.
This means exact Coach-to-normal-workout cloning is feasible with the unofficial API.
## Workout Schema Findings
`python-garminconnect.workout` encodes the normal Garmin Connect workout schema enough for a dummy cycling workout:
- Sport type: cycling has `sportTypeId=2`, `sportTypeKey="cycling"`.
- Step types include warmup, cooldown, interval, recovery, rest, repeat, other, and main.
- End conditions include lap button, time, distance, calories, power, heart rate, iterations, fixed rest, fixed repetition, and reps.
- Target types include no target, power zone, cadence, heart-rate zone, speed zone, pace zone, grade, heart-rate lap, power lap, and resistance.
The typed helpers only create simple time-based no-target steps. More exact Coach cloning will require preserving or constructing `targetType`, target values, and repeat groups from the raw Coach/adaptive JSON after the probe shows what Garmin returns.
Edge 1030 acceptance for target details is untested. The dummy workout intentionally starts with simple time-based no-target steps to separate "can upload/schedule/sync" from "can preserve all Coach targets".
## Direct Device / FIT Fallback
A local FIT export fallback is plausible but lower priority:
- Garmin's official FIT Python SDK can encode FIT files.
- FIT workout files can represent structured workout concepts through FIT profile messages, but the probe does not implement this yet.
- `fitparse`/`fitdecode` are primarily decoders and are not enough by themselves for reliable workout generation.
- Copying files to Garmin device folders is not a good primary automation path because scheduling behavior on `GARMIN/Workouts/Schedule` is not reliably documented for the Edge 1030.
The safer first proof is still Garmin Connect upload + calendar scheduling. A FIT export can become a manual fallback if Garmin Connect upload/schedule works but Coach extraction or automated scheduling remains blocked.
## Stop / Continue Decision
Do not build the web app yet.
Continue to Phase 2 probe because:
- Normal workout upload/scheduling is source-supported and can be tested with a dummy workout.
- Adaptive/Coach endpoint access is source-supported enough to probe.
Stop before Phase 3 unless the user confirms at least:
- The dummy scheduled workout appears on the Edge 1030.
- Garmin Coach/adaptive JSON contains enough step data to clone through the FBT adaptive endpoint, or the agreed fallback is summary-derived/manual template generation.
If the FBT adaptive endpoint changes or becomes unavailable, the best fallback architecture is:
- Keep Garmin Connect login/calendar/workout discovery.
- Fall back to parsing adaptive plan `taskWorkout` summaries and producing clearly labeled approximate normal workouts.
- Allow manual review/edit of generated steps before upload.
- Optionally export FIT workouts for manual device copy.
- Do not claim unattended exact Garmin Coach cloning unless browser endpoint authentication is stable.
## Sources
- Garmin Training API: <https://developer.garmin.com/gc-developer-program/training-api/>
- Garmin Developer Program FAQ: <https://developer.garmin.com/gc-developer-program/program-faq/>
- Garmin Developer Program Overview: <https://developer.garmin.com/gc-developer-program/overview/>
- Garmin Health API: <https://developer.garmin.com/gc-developer-program/health-api/>
- Edge 1030 Workouts manual: <https://www8.garmin.com/manuals/webhelp/edge1030/EN-US/GUID-99D42128-10E4-4AA4-B961-58FD70A431A0.html>
- Edge 1030 Following a Workout From Garmin Connect: <https://www8.garmin.com/manuals/webhelp/edge1030/EN-US/GUID-D6E80F0C-F319-47D1-AF95-0884F3386635.html>
- Edge 1030 Starting a Workout: <https://www8.garmin.com/manuals/webhelp/edge1030/EN-US/GUID-5CA69960-1471-47A1-867E-820FD0B240C8.html>
- Edge 1030 Training Calendar: <https://www8.garmin.com/manuals/webhelp/edge1030/EN-US/GUID-104A175C-4F28-4995-8476-42405A543F11.html>
- `python-garminconnect`: <https://github.com/cyberjunky/python-garminconnect>
- `python-garminconnect` workout models: <https://github.com/cyberjunky/python-garminconnect/blob/master/garminconnect/workout.py>
- `garth` deprecation notice: <https://github.com/matin/garth>
- Garmin FIT Python SDK: <https://github.com/garmin/fit-python-sdk>

33
docs/limitations.md Normal file
View File

@ -0,0 +1,33 @@
# Limitations
## Unofficial Garmin APIs
This project uses private Garmin Connect endpoints through `python-garminconnect`. Garmin can change authentication, endpoint paths, payload schemas, or rate limits without notice.
## Garmin Coach Extraction
Garmin Coach/adaptive cycling workout steps are exposed through the private `/workout-service/fbt-adaptive/{workoutUuid}` endpoint for the tested account. This is still an unofficial endpoint: Garmin can change it, remove fields, or change authentication without notice.
## Garmin Coach Completion
A cloned workout is a separate normal Garmin Connect workout. Completing it may not mark the original Garmin Coach workout complete.
## Target Conversion
The first real Coach JSON contains time steps, nested repeats, heart-rate range targets, and instruction targets. Power, cadence, distance, open-ended, and lap-button-until-press targets may still need more examples before conversion can be considered complete.
The CLI performs conservative local validation before upload, but that does not prove Garmin Connect or the Edge 1030 will accept every target detail. It only catches obvious malformed payloads before account writes.
Offline dump analysis is intentionally structural. It can show whether saved JSON contains dated workout-like objects and whether a clone payload can be built, but it cannot prove Garmin Connect upload or Edge behavior.
## Device File Fallback
Generating FIT workout files is plausible, but direct device folder scheduling is not yet proven for the Edge 1030. Garmin Connect scheduling remains the safer first path.
## Authentication
MFA/token behavior depends on Garmin account state, region, rate limits, and current Garmin SSO behavior. Token files should be treated as sensitive and kept out of git.
## Redaction
Debug dumps are redacted on a best-effort basis. The redactor preserves Garmin workout schema identifiers needed for conversion, such as `stepTypeId` and `workoutTargetTypeId`, while removing known account, owner, token, email, and device identifier fields. Treat all `debug/` files as private anyway.

99
docs/phase3-gate.md Normal file
View File

@ -0,0 +1,99 @@
# Phase 3 Gate
Do not build the Dockerized web app until these checks are complete.
## Local Evidence
Run:
```sh
uv run python scripts/local_check.py
```
Required result:
- All local checks pass.
- `debug/local_checks.json` is written.
This proves only that the checkout is internally consistent. It does not prove Garmin account, Garmin Connect, or Edge 1030 behavior.
## Garmin Account Evidence
Run:
```sh
uv run python scripts/probe_garmin.py report --dump-json
```
Required result:
- Garmin login succeeds, including MFA if required.
- `debug/probe_report.json` is written.
- Normal workouts are visible or the report clearly records why they are not.
- Coach/adaptive plan discovery either finds a matched workout for today/tomorrow or clearly shows that step extraction is missing.
Optional offline analysis:
```sh
uv run python scripts/analyze_dump.py debug/coach_workout_today.json --date today --clone-dry-run
```
If Coach tasks are visible but no step arrays are found, run:
```sh
uv run python scripts/probe_garmin.py coach-endpoints --date today --dump-json
```
Required result for exact cloning:
- At least one response contains `workoutSegments` or `workoutSteps`.
Current account evidence from June 16, 2026:
- The active `FBT_ADAPTIVE` cycling Coach plan is visible.
- The `Sprint` workout is visible as a dated Coach task and calendar item.
- Browser network analysis found `/gc-api/workout-service/fbt-adaptive/<workoutUuid>`, which returns `workoutSegments` and `workoutSteps`.
- CLI access works through the equivalent Connect API route `/workout-service/fbt-adaptive/<workoutUuid>`.
- `clone_today_workout.py --dry-run` succeeds from the CLI and preserves the visible step structure.
## Edge 1030 Evidence
Run:
```sh
uv run python scripts/probe_garmin.py dummy --date tomorrow --schedule
```
Then sync and test on the Edge 1030.
Required result:
- Dummy workout appears in Garmin Connect.
- Dummy workout appears under `Training > Workouts`.
- Dummy workout appears under `Training > Training Plan > calendar icon > scheduled date`.
- Dummy workout can start while normal Edge navigation is active.
## Clone Evidence
Only run if Coach/adaptive inspection shows plausible steps:
```sh
uv run python scripts/clone_today_workout.py --date today --dry-run --dump-json
uv run python scripts/clone_today_workout.py --date today --schedule
```
Required result:
- Dry-run payload passes local validation.
- Scheduled clone appears in Garmin Connect.
- Scheduled clone appears on the Edge 1030.
- Clone can start while normal Edge navigation is active.
## Decision
Build Phase 3 only if either:
- Dummy scheduling and Coach clone scheduling both work, or
- Dummy scheduling works and we explicitly choose a fallback architecture because Coach extraction is unavailable.
If dummy scheduling fails, fix or replace the Garmin Connect scheduling path before building the web app.

275
docs/testing.md Normal file
View File

@ -0,0 +1,275 @@
# Phase 2 Probe Testing
Install dependencies:
```sh
uv sync
```
Optional environment setup:
```sh
cp .env.example .env
```
Fill `GARMIN_EMAIL` and `GARMIN_PASSWORD` in `.env`, or leave them blank and the scripts will prompt. Tokens are stored in `.garmin-tokens` by default.
Garmin Coach cycling detail currently comes from this Connect API endpoint:
```text
/workout-service/fbt-adaptive/<workoutUuid>
```
The browser network analyzer shows the same route under `/gc-api/...`, but the CLI should use the Connect API route with normal `python-garminconnect` tokens. If you are debugging the browser-only `/gc-api` route specifically, you can copy the `Cookie` request header into `.env`:
```text
GARMIN_GC_API_COOKIE=JWT_WEB=...; ...
```
Treat that cookie like a password. Do not commit `.env`, screenshots containing the cookie, or debug logs with request headers.
## Local Checkout Validation
```sh
uv run python scripts/local_check.py
```
Expected:
- `PASS: ruff check .`
- `PASS: ty check`
- `PASS: pytest`
- `PASS: ... scripts/probe_garmin.py dummy --date tomorrow --dry-run`
- `debug/local_checks.json` is written.
This command does not log in to Garmin or upload anything.
## Login
```sh
uv run python scripts/probe_garmin.py login
```
Expected:
- If cached tokens work, it prints account/device summary.
- If credentials are needed, it prompts for email/password.
- If MFA is required, it prompts for the MFA code.
- It must not print the Garmin password or MFA code.
## Discovery
Preferred one-pass read-only report:
```sh
uv run python scripts/probe_garmin.py report --dump-json
```
This writes `debug/probe_report.json`. It logs in once and collects account summary, training plans, workout type metadata, normal workouts, today/tomorrow calendar entries, and today/tomorrow Coach/adaptive match summaries. It continues through partial API failures and records errors in the report.
Individual discovery commands:
```sh
uv run python scripts/probe_garmin.py plans
uv run python scripts/probe_garmin.py types
uv run python scripts/probe_garmin.py calendar --date today --dump-json
uv run python scripts/probe_garmin.py calendar --date tomorrow --dump-json
uv run python scripts/probe_garmin.py workouts --limit 25 --dump-json
uv run python scripts/probe_garmin.py generated --prefix GCClone
uv run python scripts/probe_garmin.py generated-calendar --date today --prefix GCClone
```
Check:
- Training plans are listed.
- Any plan marked `FBT_ADAPTIVE`, Coach, or adaptive is shown as a candidate.
- Workout type metadata is written to `debug/workout_types.json`.
- Calendar JSON dumps are written under `debug/`.
- Normal workouts are visible.
- Generated probe/clone workout templates are listed separately by prefix, if any exist.
- Generated scheduled calendar entries are listed separately by prefix for the inspected month, if any exist.
If you have a normal cycling workout that already syncs to the Edge 1030, inspect it:
```sh
uv run python scripts/probe_garmin.py workout <workout-id> --dump-json
```
This writes `debug/workout_<workout-id>.json`, which is useful as a known-good schema reference.
`debug/` is gitignored because dumps can contain private Garmin account data even after redaction.
## Coach Workout Inspection
```sh
uv run python scripts/probe_garmin.py coach --date today --dump-json
uv run python scripts/probe_garmin.py coach --date tomorrow --dump-json
```
Useful output:
- `Workout-like objects with workoutSegments/workoutSteps: N`
- A readable step summary with warmup/interval/recovery/cooldown/repeat steps.
- `debug/coach_workout_YYYY-MM-DD.json` when a matched workout is found.
- `debug/coach_workout_today.json` when today's matched workout is found.
- The probe should fetch the full `fbt-adaptive` detail from `workoutUuid`.
Blocking output:
- `No parseable workoutSegments/workoutSteps found for this date.`
If blocked, report the command output and keep the redacted JSON in `debug/` for inspection.
If `coach` finds dated Coach task summaries but no step arrays, run the read-only endpoint sweep:
```sh
uv run python scripts/probe_garmin.py coach-endpoints --date today --dump-json
```
Expected:
- It prints the plan ID, workout name, `workoutUuid`, and calendar item ID.
- It tries candidate private endpoints and continues through 404/errors.
- It writes `debug/coach_endpoint_probe_YYYY-MM-DD.json`.
Useful output:
- `At least one candidate endpoint returned workout-like step data.`
Blocking output:
- `No candidate endpoint returned workoutSegments/workoutSteps.`
For the June 16, 2026 account probe, `/workout-service/fbt-adaptive/<workoutUuid>` returned step arrays. `/workout-service/workout/uuid/<workoutUuid>` returned an empty list, and other candidate detail endpoints returned errors.
## Offline Dump Analysis
Analyze a saved dump without logging in to Garmin:
```sh
uv run python scripts/analyze_dump.py debug/coach_workout_today.json --date today --clone-dry-run --dump-json
```
Expected, if the dump contains a dated workout:
- Date-containing object count is shown.
- Workout-like object count is shown.
- A matched workout summary is printed.
- `Local clone payload passed validation.` is printed if conversion is structurally safe.
If the dump does not contain a dated workout-like object, the command exits non-zero and prints:
```text
No dated workout-like object was found.
```
## Dummy Upload And Schedule Test
Dry run:
```sh
uv run python scripts/probe_garmin.py dummy --date tomorrow --dry-run
```
Expected:
- `Dummy workout payload passed local validation.`
- The generated JSON is printed.
- Nothing is uploaded.
Upload and schedule:
```sh
uv run python scripts/probe_garmin.py dummy --date tomorrow --schedule
```
After upload/schedule succeeds:
1. Sync the Edge 1030 with Garmin Connect / Garmin Express.
2. Check `Training > Workouts`.
3. Check `Training > Training Plan > calendar icon > tomorrow's date`.
4. Start normal route/course navigation, then start the dummy workout.
5. Confirm whether workout prompts and navigation can run together.
Report:
- Did the dummy workout appear in Garmin Connect?
- Did it appear on the Edge 1030?
- Did it appear in the calendar/date view?
- Could it start while navigation was active?
## Clone Test
Dry run:
```sh
uv run python scripts/clone_today_workout.py --date today --dry-run --dump-json
```
Expected:
- `Converted clone payload passed local validation.`
- The converted JSON is printed.
- Nothing is uploaded.
Upload and schedule only if the dry run shows plausible steps:
```sh
uv run python scripts/clone_today_workout.py --date today --schedule
```
The clone is named:
```text
GCClone YYYY-MM-DD <original workout name>
```
Repeated runs skip an existing clone for the same date unless `--force` is passed.
Report:
- Did the cloned workout appear in Garmin Connect?
- Did it appear on the Edge 1030 under workouts/calendar?
- Can it be started on the Edge?
- Can it run together with normal Edge navigation?
- After completion, did Garmin Coach mark the original Coach workout complete?
## Generated Workout Cleanup
List generated workout templates:
```sh
uv run python scripts/probe_garmin.py generated --prefix GCClone
```
List generated scheduled calendar entries for the month containing `--date`:
```sh
uv run python scripts/probe_garmin.py generated-calendar --date today --prefix GCClone
```
Unschedule a generated calendar entry only if you are sure the scheduled ID is from the generated-prefix calendar list:
```sh
uv run python scripts/probe_garmin.py generated-calendar --date today --prefix GCClone --unschedule-id <scheduled-id> --confirm
```
Delete a generated workout template only if you are sure the ID is from the generated-prefix list:
```sh
uv run python scripts/probe_garmin.py generated --prefix GCClone --delete-id <workout-id> --confirm
```
Expected:
- Without `--delete-id` or `--unschedule-id`, these commands are read-only.
- With `--delete-id` or `--unschedule-id`, the command refuses to run unless `--confirm` is also passed.
- The commands refuse IDs that are not in the generated-prefix list.
Unknown:
- Whether deleting the workout template also removes any scheduled calendar entry. Prefer unscheduling first, then check Garmin Connect calendar afterward and report what happened.
## Current Acceptance Gate
Phase 3 should not start until the dummy scheduled workout works on the Edge 1030 and the Coach inspection either exposes parseable steps or we explicitly choose a fallback architecture.

33
pyproject.toml Normal file
View File

@ -0,0 +1,33 @@
[project]
name = "garmin-coach-to-cal-sync"
version = "0.1.0"
description = "Probe Garmin Coach cycling workout cloning feasibility."
requires-python = ">=3.12"
dependencies = [
"cryptography>=43.0.0",
"fastapi>=0.115.0",
"garminconnect[workout]>=0.2.28",
"jinja2>=3.1.4",
"python-multipart>=0.0.9",
"python-dotenv>=1.0.1",
"uvicorn[standard]>=0.30.0",
]
[dependency-groups]
dev = [
"httpx>=0.27.0",
"pytest>=8.2",
"ruff>=0.8.0",
"ty>=0.0.1a1",
]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]

110
scripts/analyze_dump.py Executable file
View File

@ -0,0 +1,110 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from garmin_coach_clone.coach import (
best_workout_for_date,
find_date_matches,
find_workout_like_objects,
)
from garmin_coach_clone.dates import parse_date
from garmin_coach_clone.io import dump_redacted_json, print_json
from garmin_coach_clone.workouts import (
clone_workout_payload,
summarize_workout,
validate_workout_payload,
)
def main() -> int:
parser = argparse.ArgumentParser(description="Analyze saved Garmin JSON without logging in.")
parser.add_argument("json_file", type=Path, help="Path to a saved JSON dump.")
parser.add_argument("--date", default="today", help="today, tomorrow, or YYYY-MM-DD")
parser.add_argument(
"--clone-dry-run",
action="store_true",
help="Build a local clone payload from the matched workout.",
)
parser.add_argument("--prefix", default="GCClone", help="Clone workout name prefix.")
parser.add_argument(
"--dump-json", action="store_true", help="Write analysis JSON under debug/."
)
args = parser.parse_args()
target_date = parse_date(args.date)
data = _load_json(args.json_file)
date_matches = find_date_matches(data, target_date)
workout_like = find_workout_like_objects(data)
matched = best_workout_for_date(data, target_date)
analysis: dict[str, Any] = {
"source": str(args.json_file),
"target_date": target_date.isoformat(),
"date_match_count": len(date_matches),
"workout_like_count": len(workout_like),
"matched_workout": matched is not None,
}
print(f"Source: {args.json_file}")
print(f"Target date: {target_date.isoformat()}")
print(f"Date-containing objects: {len(date_matches)}")
print(f"Workout-like objects with workoutSegments/workoutSteps: {len(workout_like)}")
if matched is None:
print("No dated workout-like object was found.")
if args.dump_json:
out = _analysis_path(args.json_file)
dump_redacted_json(out, analysis)
print(f"Analysis JSON written to {out}")
return 1
print("\nMatched workout summary:")
print(summarize_workout(matched))
if args.clone_dry_run:
payload = clone_workout_payload(matched, target_date, args.prefix)
errors = validate_workout_payload(payload)
analysis["clone_validation_errors"] = errors
analysis["clone_payload"] = payload
if errors:
print("\nLocal clone payload failed validation:")
for error in errors:
print(f" - {error}")
return_code = 1
else:
print("\nLocal clone payload passed validation.")
print_json(payload)
return_code = 0
else:
return_code = 0
if args.dump_json:
out = _analysis_path(args.json_file)
dump_redacted_json(out, analysis)
print(f"Analysis JSON written to {out}")
return return_code
def _load_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
raise SystemExit(f"File not found: {path}") from None
except json.JSONDecodeError as exc:
raise SystemExit(f"Invalid JSON in {path}: {exc}") from None
def _analysis_path(source: Path) -> Path:
stem = source.stem.replace(".", "_")
return Path("debug") / f"analysis_{stem}.json"
if __name__ == "__main__":
raise SystemExit(main())

144
scripts/clone_today_workout.py Executable file
View File

@ -0,0 +1,144 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from dotenv import load_dotenv
from garmin_coach_clone.auth import login
from garmin_coach_clone.coach import (
best_workout_for_date,
extract_training_plans,
get_fbt_adaptive_workout,
is_adaptive_plan,
plan_id,
task_workout_for_date,
)
from garmin_coach_clone.dates import parse_date
from garmin_coach_clone.io import dump_redacted_json, print_json
from garmin_coach_clone.workouts import (
clone_workout_payload,
existing_clone_names,
summarize_workout,
validate_workout_payload,
)
load_dotenv()
def main() -> int:
parser = argparse.ArgumentParser(
description="Clone a Garmin Coach workout if step data is exposed."
)
parser.add_argument("--date", default="today", help="today, tomorrow, or YYYY-MM-DD")
parser.add_argument("--dry-run", action="store_true", help="Convert and print JSON only.")
parser.add_argument("--schedule", action="store_true", help="Upload and schedule the clone.")
parser.add_argument(
"--force", action="store_true", help="Create even if a clone already exists."
)
parser.add_argument(
"--dump-json", action="store_true", help="Dump redacted Coach/adaptive JSON."
)
args = parser.parse_args()
target_date = parse_date(args.date)
prefix = os.getenv("CLONE_PREFIX", "GCClone")
api = login()
plans = extract_training_plans(api.get_training_plans())
candidates = [plan for plan in plans if is_adaptive_plan(plan)]
if not candidates:
print("No Garmin Coach/adaptive plan was identifiable. Cannot clone.")
return 1
source = None
source_plan_name = None
for plan in candidates:
pid = plan_id(plan)
if pid is None:
continue
detail = (
api.get_adaptive_training_plan_by_id(pid)
if str(plan.get("trainingPlanCategory", "")).upper() == "FBT_ADAPTIVE"
else api.get_training_plan_by_id(pid)
)
if args.dump_json:
out = Path("debug") / f"coach_clone_source_{pid}_{target_date.isoformat()}.json"
dump_redacted_json(out, detail)
print(f"Redacted source JSON written to {out}")
source = best_workout_for_date(detail, target_date)
if source is None:
task_workout = task_workout_for_date(detail, target_date)
workout_uuid = task_workout.get("workoutUuid") if task_workout else None
if workout_uuid:
source = get_fbt_adaptive_workout(api, str(workout_uuid))
if source is not None:
source_plan_name = plan.get("name")
break
if source is None:
print(
"Coach/adaptive plan data was fetched, but no object with workoutSegments "
f"could be matched to {target_date.isoformat()}."
)
print(
"This means the missing piece is Coach step extraction, "
"not normal workout scheduling."
)
return 1
payload = clone_workout_payload(source, target_date, prefix)
print(f"Source plan: {source_plan_name}")
print(summarize_workout(payload))
validation_errors = validate_workout_payload(payload)
if validation_errors:
print("\nConverted clone payload failed local validation. Nothing was uploaded.")
for error in validation_errors:
print(f" - {error}")
return 1
print("\nConverted clone payload passed local validation.")
existing = existing_clone_names(api.get_workouts(limit=100), target_date, prefix)
if existing and not args.force:
print("A clone already exists for this date; skipping upload:")
for name in existing:
print(f" - {name}")
return 0
if args.dry_run or not args.schedule:
print("\nDry run: converted clone JSON follows. Nothing was uploaded.")
print_json(payload)
if not args.schedule:
print("\nPass --schedule to upload and schedule this clone.")
return 0
result = api.upload_workout(payload)
workout_id = result.get("workoutId") or result.get("id")
if workout_id is None:
print("Upload returned no workoutId. Raw response:")
print_json(result)
return 1
schedule_result = api.schedule_workout(workout_id, target_date.isoformat())
print(f"Uploaded clone workout ID: {workout_id}")
print("Schedule response:")
print_json(schedule_result)
print(
f"""
Next checks on the Edge 1030 after Garmin Connect sync:
1. Check Training > Workouts for "{payload["workoutName"]}".
2. Check Training > Training Plan > calendar icon > {target_date.isoformat()}.
3. Start it with a normal route/course active and verify navigation still works.
4. After completing, check whether Garmin Coach marks anything complete; this is not expected yet.
"""
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

64
scripts/local_check.py Executable file
View File

@ -0,0 +1,64 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import subprocess
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
COMMANDS = [
["ruff", "check", "."],
["ty", "check"],
["pytest"],
[
sys.executable,
"scripts/probe_garmin.py",
"dummy",
"--date",
"tomorrow",
"--dry-run",
],
]
def main() -> int:
results = [_run(command) for command in COMMANDS]
passed = all(result["returncode"] == 0 for result in results)
report: dict[str, Any] = {
"timestamp": datetime.now(tz=UTC).isoformat(),
"passed": passed,
"results": results,
}
out = ROOT / "debug" / "local_checks.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report, indent=2), encoding="utf-8")
for result in results:
status = "PASS" if result["returncode"] == 0 else "FAIL"
print(f"{status}: {' '.join(result['command'])}")
print(f"Local check report written to {out.relative_to(ROOT)}")
return 0 if passed else 1
def _run(command: list[str]) -> dict[str, Any]:
completed = subprocess.run(
command,
cwd=ROOT,
text=True,
capture_output=True,
check=False,
)
return {
"command": command,
"returncode": completed.returncode,
"stdout": completed.stdout,
"stderr": completed.stderr,
}
if __name__ == "__main__":
raise SystemExit(main())

706
scripts/probe_garmin.py Executable file
View File

@ -0,0 +1,706 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import sys
from collections.abc import Callable
from datetime import date, timedelta
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from dotenv import load_dotenv
from garmin_coach_clone.auth import account_summary, login
from garmin_coach_clone.coach import (
best_workout_for_date,
describe_plans,
extract_training_plans,
find_date_matches,
find_workout_like_objects,
gc_api_get,
get_fbt_adaptive_workout,
is_adaptive_plan,
plan_id,
task_workout_for_date,
)
from garmin_coach_clone.dates import parse_date
from garmin_coach_clone.io import dump_redacted_json, print_json
from garmin_coach_clone.workouts import (
build_dummy_cycling_workout,
calendar_entry_date,
calendar_entry_id,
calendar_entry_name,
find_generated_calendar_entry,
find_generated_workout,
generated_calendar_entries,
generated_workouts,
summarize_workout,
validate_workout_payload,
)
load_dotenv()
def main() -> int:
parser = argparse.ArgumentParser(description="Probe Garmin Connect workout feasibility.")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("login", help="Authenticate and print account/device summary.")
sub.add_parser("plans", help="List training plans and adaptive/Coach candidates.")
sub.add_parser("types", help="Dump Garmin Connect workout type metadata.")
report = sub.add_parser("report", help="Run read-only feasibility checks in one pass.")
report.add_argument("--limit", type=int, default=25, help="Normal workout list limit.")
report.add_argument("--dump-json", action="store_true", help="Write debug/probe_report.json.")
calendar = sub.add_parser("calendar", help="List calendar entries for a date.")
calendar.add_argument("--date", default="today", help="today, tomorrow, or YYYY-MM-DD")
calendar.add_argument("--dump-json", action="store_true")
coach = sub.add_parser("coach", help="Inspect Coach/adaptive workout data for a date.")
coach.add_argument("--date", default="today", help="today, tomorrow, or YYYY-MM-DD")
coach.add_argument("--dump-json", action="store_true")
coach_endpoints = sub.add_parser(
"coach-endpoints",
help="Try read-only candidate endpoints for hidden Coach workout details.",
)
coach_endpoints.add_argument("--date", default="today", help="today, tomorrow, or YYYY-MM-DD")
coach_endpoints.add_argument(
"--dump-json", action="store_true", help="Write debug/coach_endpoint_probe_*.json."
)
workouts = sub.add_parser("workouts", help="List normal workouts visible via the API.")
workouts.add_argument("--limit", type=int, default=25)
workouts.add_argument("--dump-json", action="store_true")
workout = sub.add_parser("workout", help="Fetch and summarize a normal workout by ID.")
workout.add_argument("workout_id", help="Garmin Connect workout ID")
workout.add_argument("--dump-json", action="store_true")
generated = sub.add_parser("generated", help="List/delete generated probe or clone workouts.")
generated.add_argument("--prefix", default=os.getenv("CLONE_PREFIX", "GCClone"))
generated.add_argument("--limit", type=int, default=100)
generated.add_argument("--delete-id", help="Delete a generated workout template by ID.")
generated.add_argument("--confirm", action="store_true", help="Required with --delete-id.")
generated_calendar = sub.add_parser(
"generated-calendar", help="List/unschedule generated calendar entries for a month."
)
generated_calendar.add_argument(
"--date", default="today", help="Any date in the month to inspect."
)
generated_calendar.add_argument("--prefix", default=os.getenv("CLONE_PREFIX", "GCClone"))
generated_calendar.add_argument(
"--unschedule-id", help="Unschedule a generated calendar entry by scheduled ID."
)
generated_calendar.add_argument(
"--dump-json", action="store_true", help="Write the redacted month calendar JSON."
)
generated_calendar.add_argument(
"--confirm", action="store_true", help="Required with --unschedule-id."
)
dummy = sub.add_parser("dummy", help="Create and optionally schedule a dummy cycling workout.")
dummy.add_argument("--date", default="tomorrow", help="today, tomorrow, or YYYY-MM-DD")
dummy.add_argument("--dry-run", action="store_true", help="Print JSON without uploading.")
dummy.add_argument("--schedule", action="store_true", help="Upload and schedule the workout.")
args = parser.parse_args()
match args.command:
case "login":
return command_login()
case "plans":
return command_plans()
case "types":
return command_types()
case "report":
return command_report(args.limit, args.dump_json)
case "calendar":
return command_calendar(parse_date(args.date), args.dump_json)
case "coach":
return command_coach(parse_date(args.date), args.dump_json)
case "coach-endpoints":
return command_coach_endpoints(parse_date(args.date), args.dump_json)
case "workouts":
return command_workouts(args.limit, args.dump_json)
case "workout":
return command_workout(args.workout_id, args.dump_json)
case "generated":
return command_generated(args.prefix, args.limit, args.delete_id, args.confirm)
case "generated-calendar":
return command_generated_calendar(
parse_date(args.date),
args.prefix,
args.unschedule_id,
args.dump_json,
args.confirm,
)
case "dummy":
return command_dummy(parse_date(args.date), args.dry_run, args.schedule)
return 2
def command_login() -> int:
api = login()
print("Authenticated.")
print_json(account_summary(api))
return 0
def command_plans() -> int:
api = login()
response = api.get_training_plans()
plans = extract_training_plans(response)
print(describe_plans(plans))
adaptive = [plan for plan in plans if is_adaptive_plan(plan)]
if adaptive:
print("\nAdaptive/Coach-looking plans:")
print(describe_plans(adaptive))
else:
print("\nNo adaptive/Coach-looking plans were identifiable from the plan list.")
return 0
def command_types() -> int:
api = login()
data = api.connectapi("/workout-service/workout/types")
out = Path("debug") / "workout_types.json"
dump_redacted_json(out, data)
print(f"Redacted Garmin workout type metadata written to {out}")
print_json(data)
return 0
def command_report(limit: int, dump_json: bool) -> int:
api = login()
today = date.today()
tomorrow = today + timedelta(days=1)
target_dates = [today, tomorrow]
report: dict[str, Any] = {"dates": [day.isoformat() for day in target_dates]}
print("Authenticated.")
report["account"] = account_summary(api)
_print_section("Account")
print_json(report["account"])
plans_response = _capture("training_plans", report, api.get_training_plans)
plans = extract_training_plans(plans_response)
candidates = [plan for plan in plans if is_adaptive_plan(plan)]
report["training_plan_summary"] = {
"count": len(plans),
"adaptive_candidate_count": len(candidates),
"adaptive_candidates": [_plan_summary(plan) for plan in candidates],
}
_print_section("Training Plans")
print(f"Plans returned: {len(plans)}")
print(f"Adaptive/Coach-looking candidates: {len(candidates)}")
if candidates:
print(describe_plans(candidates))
types_response = _capture(
"workout_types", report, lambda: api.connectapi("/workout-service/workout/types")
)
_print_section("Workout Type Metadata")
print("Fetched." if not _is_error(types_response) else types_response["error"])
workouts = _capture("workouts", report, lambda: api.get_workouts(limit=limit))
workout_count = len(workouts) if isinstance(workouts, list) else 0
_print_section("Normal Workouts")
print(f"Normal workouts returned: {workout_count}")
if isinstance(workouts, list):
for workout in workouts[:10]:
print(
" - id={id} sport={sport} name={name}".format(
id=workout.get("workoutId"),
sport=(workout.get("sportType") or {}).get("sportTypeKey"),
name=workout.get("workoutName"),
)
)
report["calendar_summary"] = {}
_print_section("Calendar")
for target_date in target_dates:
key = target_date.isoformat()
calendar_data = _capture(
f"calendar_{key}",
report,
lambda target_date=target_date: api.get_scheduled_workouts(
target_date.year, target_date.month
),
)
matches = find_date_matches(calendar_data, target_date)
report["calendar_summary"][key] = {"date_match_count": len(matches)}
print(f"{key}: {len(matches)} date-containing entries")
report["coach_summary"] = {}
_print_section("Coach / Adaptive")
if not candidates:
print("No adaptive/Coach-looking plans found.")
for plan in candidates:
pid = plan_id(plan)
if pid is None:
continue
category = str(plan.get("trainingPlanCategory", "")).upper()
detail = _capture(
f"coach_plan_{pid}",
report,
lambda pid=pid, category=category: api.get_adaptive_training_plan_by_id(pid)
if category == "FBT_ADAPTIVE"
else api.get_training_plan_by_id(pid),
)
plan_summary: dict[str, Any] = {
"name": plan.get("name"),
"category": plan.get("trainingPlanCategory"),
"workout_like_count": len(find_workout_like_objects(detail)),
"dates": {},
}
for target_date in target_dates:
workout = best_workout_for_date(detail, target_date)
plan_summary["dates"][target_date.isoformat()] = {
"date_match_count": len(find_date_matches(detail, target_date)),
"matched_workout": workout is not None,
}
report["coach_summary"][str(pid)] = plan_summary
print(
"Plan {pid} {name}: workout-like={count}, today={today_match}, "
"tomorrow={tomorrow_match}".format(
pid=pid,
name=plan.get("name"),
count=plan_summary["workout_like_count"],
today_match=plan_summary["dates"][today.isoformat()]["matched_workout"],
tomorrow_match=plan_summary["dates"][tomorrow.isoformat()]["matched_workout"],
)
)
if dump_json:
out = Path("debug") / "probe_report.json"
dump_redacted_json(out, report)
print(f"\nRedacted probe report written to {out}")
_print_section("Next Gate")
print(
"If dummy upload/schedule works on the Edge and Coach matched_workout is true, "
"run clone dry-run."
)
print("If Coach matched_workout is false, Coach step extraction is still the missing piece.")
return 0
def command_calendar(target_date: date, dump_json: bool) -> int:
api = login()
data = api.get_scheduled_workouts(target_date.year, target_date.month)
matches = find_date_matches(data, target_date)
print(f"Calendar entries containing {target_date.isoformat()}: {len(matches)}")
for idx, item in enumerate(matches, start=1):
name = item.get("workoutName") or item.get("name") or item.get("title") or "<unnamed>"
item_id = item.get("workoutId") or item.get("scheduledWorkoutId") or item.get("id")
print(f" {idx}. id={item_id} name={name}")
if dump_json:
out = Path("debug") / f"calendar_{target_date.isoformat()}.json"
dump_redacted_json(out, data)
print(f"Redacted calendar JSON written to {out}")
return 0
def command_coach(target_date: date, dump_json: bool) -> int:
api = login()
plans_response = api.get_training_plans()
plans = extract_training_plans(plans_response)
candidates = [plan for plan in plans if is_adaptive_plan(plan)]
if not candidates:
print("No Garmin Coach/adaptive plan was identifiable in get_training_plans().")
if dump_json:
out = Path("debug") / "training_plans.json"
dump_redacted_json(out, plans_response)
print(f"Redacted training plan list written to {out}")
return 1
any_workout = False
for plan in candidates:
pid = plan_id(plan)
if pid is None:
print(f"Skipping plan without numeric ID: {plan.get('name')}")
continue
category = str(plan.get("trainingPlanCategory", "")).upper()
if category == "FBT_ADAPTIVE":
detail = api.get_adaptive_training_plan_by_id(pid)
else:
detail = api.get_training_plan_by_id(pid)
if dump_json:
out = Path("debug") / f"coach_plan_{pid}_{target_date.isoformat()}.json"
dump_redacted_json(out, detail)
print(f"Redacted plan detail written to {out}")
workout = best_workout_for_date(detail, target_date)
if workout is None:
workout = _fetch_fbt_workout_for_date(api, detail, target_date)
date_matches = find_date_matches(detail, target_date)
workout_like = find_workout_like_objects(detail)
print(f"\nPlan {pid}: {plan.get('name')}")
print(f"Date matches for {target_date.isoformat()}: {len(date_matches)}")
print(f"Workout-like objects with workoutSegments/workoutSteps: {len(workout_like)}")
if workout:
any_workout = True
print(summarize_workout(workout))
if dump_json:
out = Path("debug") / f"coach_workout_{target_date.isoformat()}.json"
dump_redacted_json(out, workout)
print(f"Redacted matched workout JSON written to {out}")
if target_date == date.today():
today_out = Path("debug") / "coach_workout_today.json"
dump_redacted_json(today_out, workout)
print(f"Redacted matched workout JSON also written to {today_out}")
else:
print("No parseable workoutSegments/workoutSteps found for this date.")
return 0 if any_workout else 1
def command_coach_endpoints(target_date: date, dump_json: bool) -> int:
api = login()
plans = extract_training_plans(api.get_training_plans())
candidates = [plan for plan in plans if is_adaptive_plan(plan)]
if not candidates:
print("No Garmin Coach/adaptive plan was identifiable in get_training_plans().")
return 1
report: dict[str, Any] = {"date": target_date.isoformat(), "plans": []}
found_workout_like = False
for plan in candidates:
pid = plan_id(plan)
if pid is None:
continue
detail = (
api.get_adaptive_training_plan_by_id(pid)
if str(plan.get("trainingPlanCategory", "")).upper() == "FBT_ADAPTIVE"
else api.get_training_plan_by_id(pid)
)
workout = task_workout_for_date(detail, target_date)
if workout is None:
print(f"Plan {pid}: no taskWorkout found for {target_date.isoformat()}.")
continue
uuid = workout.get("workoutUuid")
calendar_id = _calendar_item_id_for_workout(api, target_date, pid, uuid)
endpoints = _coach_endpoint_candidates(pid, uuid, calendar_id, target_date)
plan_report: dict[str, Any] = {
"plan_id": pid,
"plan_name": plan.get("name"),
"workout_name": workout.get("workoutName"),
"workout_uuid": uuid,
"calendar_item_id": calendar_id,
"endpoints": [],
}
report["plans"].append(plan_report)
print(f"\nPlan {pid}: {plan.get('name')}")
print(f"Workout: {workout.get('workoutName')} uuid={uuid} calendar_id={calendar_id}")
for endpoint in endpoints:
result = _capture_endpoint(api, endpoint)
workout_like_count = len(find_workout_like_objects(result))
if workout_like_count:
found_workout_like = True
plan_report["endpoints"].append(
{
"endpoint": endpoint,
"result": result,
"workout_like_count": workout_like_count,
}
)
status = "error" if _is_error(result) else "ok"
keys = ", ".join(result.keys()) if isinstance(result, dict) else type(result).__name__
print(f" - {status}: {endpoint} workout_like={workout_like_count} keys={keys}")
if dump_json:
out = Path("debug") / f"coach_endpoint_probe_{target_date.isoformat()}.json"
dump_redacted_json(out, report)
print(f"\nRedacted Coach endpoint probe written to {out}")
if found_workout_like:
print("\nAt least one candidate endpoint returned workout-like step data.")
return 0
print("\nNo candidate endpoint returned workoutSegments/workoutSteps.")
return 1
def command_workouts(limit: int, dump_json: bool) -> int:
api = login()
workouts = api.get_workouts(limit=limit)
print(f"Normal workouts returned: {len(workouts)}")
for workout in workouts:
print(
" - id={id} sport={sport} name={name}".format(
id=workout.get("workoutId"),
sport=(workout.get("sportType") or {}).get("sportTypeKey"),
name=workout.get("workoutName"),
)
)
if dump_json:
out = Path("debug") / "workouts.json"
dump_redacted_json(out, workouts)
print(f"Redacted workout list written to {out}")
return 0
def command_workout(workout_id: str, dump_json: bool) -> int:
api = login()
workout = api.get_workout_by_id(workout_id)
print(summarize_workout(workout))
if dump_json:
out = Path("debug") / f"workout_{workout_id}.json"
dump_redacted_json(out, workout)
print(f"Redacted workout JSON written to {out}")
return 0
def command_generated(prefix: str, limit: int, delete_id: str | None, confirm: bool) -> int:
api = login()
workouts = api.get_workouts(limit=limit)
generated = generated_workouts(workouts, prefix)
print(f"Generated workouts with prefix {prefix!r}: {len(generated)}")
for workout in generated:
print(
" - id={id} sport={sport} name={name}".format(
id=workout.get("workoutId") or workout.get("id"),
sport=(workout.get("sportType") or {}).get("sportTypeKey"),
name=workout.get("workoutName"),
)
)
if delete_id is None:
return 0
if not confirm:
print("\nRefusing to delete without --confirm.")
return 1
selected = find_generated_workout(workouts, delete_id, prefix)
if selected is None:
print(f"\nRefusing to delete {delete_id}: not found among generated workouts.")
return 1
name = selected.get("workoutName")
api.delete_workout(delete_id)
print(f"\nDeleted generated workout template {delete_id}: {name}")
return 0
def command_generated_calendar(
target_date: date,
prefix: str,
unschedule_id: str | None,
dump_json: bool,
confirm: bool,
) -> int:
api = login()
calendar_data = api.get_scheduled_workouts(target_date.year, target_date.month)
generated = generated_calendar_entries(calendar_data, prefix)
print(
f"Generated calendar entries with prefix {prefix!r} in "
f"{target_date.year:04d}-{target_date.month:02d}: {len(generated)}"
)
for entry in generated:
print(
" - scheduled_id={id} date={date} name={name}".format(
id=calendar_entry_id(entry) or "<unknown>",
date=calendar_entry_date(entry) or "<unknown>",
name=calendar_entry_name(entry) or "<unnamed>",
)
)
if dump_json:
out = Path("debug") / f"generated_calendar_{target_date:%Y_%m}.json"
dump_redacted_json(out, calendar_data)
print(f"Redacted calendar JSON written to {out}")
if unschedule_id is None:
return 0
if not confirm:
print("\nRefusing to unschedule without --confirm.")
return 1
selected = find_generated_calendar_entry(calendar_data, unschedule_id, prefix)
if selected is None:
print(
f"\nRefusing to unschedule {unschedule_id}: "
"not found among generated calendar entries."
)
return 1
api.unschedule_workout(unschedule_id)
print(
"\nUnscheduled generated calendar entry {id}: {name}".format(
id=unschedule_id,
name=calendar_entry_name(selected) or "<unnamed>",
)
)
return 0
def command_dummy(target_date: date, dry_run: bool, schedule: bool) -> int:
payload = build_dummy_cycling_workout()
validation_errors = validate_workout_payload(payload)
if validation_errors:
print("Dummy workout payload failed local validation:")
for error in validation_errors:
print(f" - {error}")
return 1
print("Dummy workout payload passed local validation.")
if dry_run or not schedule:
print("Dry run: dummy cycling workout JSON follows. Nothing was uploaded.")
print_json(payload)
if not schedule:
print("\nPass --schedule to upload and schedule this dummy workout.")
return 0
api = login()
result: dict[str, Any] = api.upload_workout(payload)
workout_id = result.get("workoutId") or result.get("id")
if workout_id is None:
print("Upload returned no workoutId. Raw response:")
print_json(result)
return 1
schedule_result = api.schedule_workout(workout_id, target_date.isoformat())
print(f"Uploaded dummy workout ID: {workout_id}")
print("Schedule response:")
print_json(schedule_result)
print_edge_check_instructions(target_date)
return 0
def print_edge_check_instructions(target_date: date) -> None:
print(
f"""
Next checks on the Edge 1030 after Garmin Connect sync:
1. Sync the Edge 1030 with Garmin Connect / Garmin Express.
2. Check Training > Workouts for the dummy cycling workout.
3. Check Training > Training Plan > calendar icon > {target_date.isoformat()}.
4. Start a normal course/navigation route, then start the workout and verify both can run.
"""
)
def _capture(label: str, report: dict[str, Any], func: Callable[[], Any]) -> Any:
try:
result = func()
except Exception as exc: # noqa: BLE001 - report should continue through partial failures
result = {"error": f"{type(exc).__name__}: {exc}"}
report[label] = result
return result
def _capture_endpoint(api: Any, endpoint: str) -> Any:
try:
if endpoint.startswith("/gc-api/"):
return gc_api_get(api, endpoint)
return api.connectapi(endpoint)
except Exception as exc: # noqa: BLE001 - endpoint probing should continue
return {"error": f"{type(exc).__name__}: {exc}"}
def _calendar_item_id_for_workout(
api: Any, target_date: date, plan_id_value: int, workout_uuid: Any
) -> str | None:
if workout_uuid is None:
return None
calendar_data = _capture_endpoint(
api, f"/calendar-service/year/{target_date.year}/month/{target_date.month - 1}"
)
if _is_error(calendar_data):
return None
for match in find_date_matches(calendar_data, target_date):
if (
match.get("itemType") == "fbtAdaptiveWorkout"
and str(match.get("trainingPlanId")) == str(plan_id_value)
and str(match.get("workoutUuid")) == str(workout_uuid)
):
item_id = match.get("id")
return str(item_id) if item_id is not None else None
return None
def _coach_endpoint_candidates(
plan_id_value: int, workout_uuid: Any, calendar_item_id: str | None, target_date: date
) -> list[str]:
endpoints = [
f"/gc-api/workout-service/fbt-adaptive/{workout_uuid}"
if workout_uuid is not None
else "",
f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}",
f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/workouts",
f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/workouts/{target_date.isoformat()}",
f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/tasks",
f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/tasks/{target_date.isoformat()}",
]
endpoints = [endpoint for endpoint in endpoints if endpoint]
if workout_uuid is not None:
uuid = str(workout_uuid)
endpoints.extend(
[
f"/workout-service/fbt-adaptive/{uuid}",
f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/workout/{uuid}",
f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/workouts/{uuid}",
f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/task/{uuid}",
f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/tasks/{uuid}",
f"/trainingplan-service/trainingplan/fbt-adaptive/workout/{uuid}",
f"/trainingplan-service/trainingplan/fbt-adaptive/workouts/{uuid}",
f"/trainingplan-service/trainingplan/workout/{uuid}",
f"/workout-service/workout/{uuid}",
f"/workout-service/workouts/{uuid}",
f"/workout-service/workout/uuid/{uuid}",
f"/workout-service/workouts/uuid/{uuid}",
]
)
if calendar_item_id is not None:
endpoints.extend(
[
f"/workout-service/schedule/{calendar_item_id}",
f"/calendar-service/{calendar_item_id}",
f"/calendar-service/calendar/{calendar_item_id}",
f"/calendar-service/event/{calendar_item_id}",
]
)
return list(dict.fromkeys(endpoints))
def _fetch_fbt_workout_for_date(
api: Any, plan_detail: Any, target_date: date
) -> dict[str, Any] | None:
workout = task_workout_for_date(plan_detail, target_date)
if workout is None:
return None
workout_uuid = workout.get("workoutUuid")
if not workout_uuid:
return None
try:
return get_fbt_adaptive_workout(api, str(workout_uuid))
except Exception as exc: # noqa: BLE001 - shown as probe output
print(
f"FBT adaptive workout detail fetch failed for {workout_uuid}: "
f"{type(exc).__name__}: {exc}"
)
return None
def _is_error(value: Any) -> bool:
return isinstance(value, dict) and isinstance(value.get("error"), str)
def _print_section(title: str) -> None:
print(f"\n== {title} ==")
def _plan_summary(plan: dict[str, Any]) -> dict[str, Any]:
return {
"id": plan.get("trainingPlanId") or plan.get("planId") or plan.get("id"),
"name": plan.get("name"),
"category": plan.get("trainingPlanCategory"),
}
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,6 @@
"""Garmin Coach clone probe package."""
__all__ = ["__version__"]
__version__ = "0.1.0"

View File

@ -0,0 +1,599 @@
from __future__ import annotations
import asyncio
import json
from contextlib import asynccontextmanager
from datetime import date, datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from fastapi import Depends, FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import Response as FastAPIResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from garminconnect import (
GarminConnectAuthenticationError,
GarminConnectConnectionError,
GarminConnectTooManyRequestsError,
)
from .coach import extract_training_plans, is_adaptive_plan, task_for_date
from .config import load_settings
from .crypto import Crypto
from .db import Database
from .garmin_service import GarminService
from .repository import Repository, ScheduleConfig, validate_schedule_config
from .sync_service import SyncService, next_run_after
from .web_auth import clear_session, create_session, current_user, require_auth
from .workouts import calendar_entry_date, calendar_entry_name, summarize_workout
def create_app() -> FastAPI:
settings = load_settings()
for path in (settings.data_dir, settings.token_dir, settings.log_dir, settings.debug_dir):
path.mkdir(parents=True, exist_ok=True)
db = Database(settings.db_path)
db.initialize()
repo = Repository(db, settings)
crypto = Crypto(settings.app_secret_key)
_bootstrap_env_user(repo, crypto, settings)
garmin = GarminService(settings, repo, crypto)
sync = SyncService(settings, repo, garmin)
templates = Jinja2Templates(directory=settings.template_dir)
@asynccontextmanager
async def lifespan(_app: FastAPI):
sync.start()
try:
yield
finally:
await sync.stop()
app = FastAPI(title="Garmin Coach Clone", lifespan=lifespan)
app.state.settings = settings
app.state.repo = repo
app.state.crypto = crypto
app.state.garmin = garmin
app.state.sync = sync
app.state.templates = templates
if settings.static_dir.exists():
app.mount("/static", StaticFiles(directory=settings.static_dir), name="static")
@app.get("/healthz")
def healthz() -> dict[str, Any]:
return {
"ok": True,
"app_configured": repo.app_configured(),
"garmin_configured": repo.garmin_configured(),
"schedule": repo.schedule_config().__dict__,
}
@app.get("/", response_class=HTMLResponse)
def index(request: Request) -> Any:
if not repo.app_configured():
return RedirectResponse("/setup", status_code=303)
if current_user(request) is None:
return RedirectResponse("/login", status_code=303)
status = repo.sync_status()
schedule = repo.schedule_config()
next_run = next_run_after(datetime.now(ZoneInfo(settings.timezone)), schedule)
return templates.TemplateResponse(
request,
"dashboard.html",
{
"request": request,
"user": current_user(request),
"status": status,
"garmin_configured": repo.garmin_configured(),
"schedule": schedule,
"next_run": next_run,
"today": date.today().isoformat(),
"tomorrow": (date.today() + timedelta(days=1)).isoformat(),
},
)
@app.get("/setup", response_class=HTMLResponse)
def setup_page(request: Request) -> Any:
if repo.app_configured():
return RedirectResponse("/login", status_code=303)
return templates.TemplateResponse(
request, "setup.html", {"request": request, "error": None}
)
@app.post("/setup")
def setup_submit(
request: Request,
username: str = Form(...),
password: str = Form(...),
confirm_password: str = Form(...),
) -> Any:
if repo.app_configured():
return RedirectResponse("/login", status_code=303)
username = username.strip()
if len(username) < 2:
return _render_setup_error(request, "Username must be at least 2 characters.")
if len(password) < 8:
return _render_setup_error(request, "Password must be at least 8 characters.")
if password != confirm_password:
return _render_setup_error(request, "Passwords do not match.")
repo.bootstrap_app_user(username, crypto.hash_password(password))
redirect = RedirectResponse("/", status_code=303)
create_session(redirect, settings, crypto, username)
return redirect
@app.get("/login", response_class=HTMLResponse)
def login_page(request: Request) -> Any:
if not repo.app_configured():
return RedirectResponse("/setup", status_code=303)
return templates.TemplateResponse(
request, "login.html", {"request": request, "error": None}
)
@app.post("/login")
def login_submit(
request: Request,
username: str = Form(...),
password: str = Form(...),
) -> Any:
stored_user = repo.app_username()
stored_hash = repo.app_password_hash()
if (
stored_user is None
or stored_hash is None
or username != stored_user
or not crypto.verify_password(password, stored_hash)
):
return templates.TemplateResponse(
request,
"login.html",
{"request": request, "error": "Invalid username or password."},
status_code=401,
)
redirect = RedirectResponse("/", status_code=303)
create_session(redirect, settings, crypto, username)
return redirect
@app.post("/logout")
def logout() -> Any:
redirect = RedirectResponse("/login", status_code=303)
clear_session(redirect, settings)
return redirect
@app.get("/garmin", response_class=HTMLResponse, dependencies=[Depends(require_auth)])
def garmin_page(request: Request) -> Any:
return templates.TemplateResponse(
request,
"garmin.html",
{
"request": request,
"configured": garmin.configured(),
"mfa_pending": bool(repo.get_setting("garmin_mfa_state")),
"error": None,
"message": None,
},
)
@app.post("/garmin/setup", dependencies=[Depends(require_auth)])
async def garmin_setup(
request: Request, email: str = Form(...), password: str = Form(...)
) -> Any:
garmin.save_credentials(email.strip(), password)
try:
result = await asyncio.to_thread(garmin.setup_login)
return templates.TemplateResponse(
request,
"garmin.html",
{
"request": request,
"configured": result["status"] == "ok",
"mfa_pending": result["status"] == "mfa_required",
"error": None,
"message": (
"MFA required."
if result["status"] == "mfa_required"
else "Garmin login OK."
),
},
)
except (GarminConnectAuthenticationError, GarminConnectTooManyRequestsError) as exc:
return _garmin_error(request, str(exc), 401)
except GarminConnectConnectionError as exc:
return _garmin_error(request, str(exc), 502)
@app.post("/garmin/mfa", dependencies=[Depends(require_auth)])
async def garmin_mfa(request: Request, code: str = Form(...)) -> Any:
try:
await asyncio.to_thread(garmin.resume_mfa, code.strip())
return templates.TemplateResponse(
request,
"garmin.html",
{
"request": request,
"configured": True,
"mfa_pending": False,
"error": None,
"message": "Garmin MFA completed.",
},
)
except (ValueError, GarminConnectAuthenticationError) as exc:
return _garmin_error(request, str(exc), 400)
@app.post("/sync/run", dependencies=[Depends(require_auth)])
async def sync_run(date_value: str = Form(""), dry_run: str | None = Form(None)) -> Any:
dates = None
if date_value:
dates = [date.fromisoformat(date_value)]
await sync.run_change_detection("manual", target_dates=dates, dry_run=dry_run == "on")
return RedirectResponse("/", status_code=303)
@app.get("/schedule", response_class=HTMLResponse, dependencies=[Depends(require_auth)])
def schedule_page(request: Request) -> Any:
return templates.TemplateResponse(
request,
"schedule.html",
{
"request": request,
"schedule": repo.schedule_config(),
"error": None,
"message": None,
},
)
@app.post("/schedule", dependencies=[Depends(require_auth)])
def schedule_save(
request: Request,
enabled: str | None = Form(None),
interval_minutes: int = Form(...),
active_window: str = Form(...),
fixed_times: str = Form(...),
days_ahead: int = Form(...),
) -> Any:
config = ScheduleConfig(
enabled=enabled == "on",
interval_minutes=interval_minutes,
active_window=active_window.strip(),
fixed_times=[item.strip() for item in fixed_times.split(",") if item.strip()],
days_ahead=days_ahead,
)
try:
validate_schedule_config(config)
repo.save_schedule_config(config)
message = "Schedule saved."
error = None
except ValueError as exc:
message = None
error = str(exc)
return templates.TemplateResponse(
request,
"schedule.html",
{"request": request, "schedule": config, "error": error, "message": message},
status_code=400 if error else 200,
)
@app.post("/schedule/restore", dependencies=[Depends(require_auth)])
def schedule_restore() -> Any:
repo.restore_default_schedule()
return RedirectResponse("/schedule", status_code=303)
@app.get("/search", response_class=HTMLResponse, dependencies=[Depends(require_auth)])
async def search_page(
request: Request,
start: str = "",
end: str = "",
sport: str = "",
q: str = "",
source: str = "all",
) -> Any:
today = date.today()
start_date = date.fromisoformat(start) if start else today
end_date = date.fromisoformat(end) if end else today + timedelta(days=1)
items: list[dict[str, Any]] = []
error = None
try:
items = await asyncio.to_thread(
_search_items, garmin, repo, start_date, end_date, sport, q, source
)
except Exception as exc:
error = str(exc)
return templates.TemplateResponse(
request,
"search.html",
{
"request": request,
"items": items,
"error": error,
"filters": {
"start": start_date.isoformat(),
"end": end_date.isoformat(),
"sport": sport,
"q": q,
"source": source,
},
},
)
@app.get("/logs", response_class=HTMLResponse, dependencies=[Depends(require_auth)])
def logs_page(request: Request) -> Any:
return templates.TemplateResponse(
request, "logs.html", {"request": request, "events": repo.list_events()}
)
@app.get("/traces", response_class=HTMLResponse, dependencies=[Depends(require_auth)])
def traces_page(request: Request) -> Any:
return templates.TemplateResponse(
request, "traces.html", {"request": request, "traces": repo.list_traces()}
)
@app.get(
"/traces/{trace_id}",
response_class=HTMLResponse,
dependencies=[Depends(require_auth)],
)
def trace_detail(request: Request, trace_id: int) -> Any:
trace = repo.get_trace(trace_id)
if trace is None:
raise HTTPException(status_code=404, detail="Trace not found")
return templates.TemplateResponse(
request,
"trace.html",
{"request": request, "trace": trace, "json": _pretty_trace_json(trace)},
)
@app.get("/traces/{trace_id}/{kind}.json", dependencies=[Depends(require_auth)])
def trace_json(trace_id: int, kind: str) -> FastAPIResponse:
trace = repo.get_trace(trace_id)
if trace is None or kind not in {"source", "payload", "result"}:
raise HTTPException(status_code=404, detail="Trace JSON not found")
value = trace.get(f"{kind}_json")
if not value:
raise HTTPException(status_code=404, detail="Trace JSON not found")
return FastAPIResponse(value, media_type="application/json")
def _render_setup_error(request: Request, error: str) -> Any:
return templates.TemplateResponse(
request,
"setup.html",
{"request": request, "error": error},
status_code=400,
)
def _garmin_error(request: Request, error: str, status_code: int) -> Any:
return templates.TemplateResponse(
request,
"garmin.html",
{
"request": request,
"configured": garmin.configured(),
"mfa_pending": bool(repo.get_setting("garmin_mfa_state")),
"error": error,
"message": None,
},
status_code=status_code,
)
return app
def _bootstrap_env_user(repo: Repository, crypto: Crypto, settings: Any) -> None:
if repo.app_configured():
return
if settings.app_username and settings.app_password and settings.app_password != "change-me":
repo.bootstrap_app_user(settings.app_username, crypto.hash_password(settings.app_password))
def _pretty_trace_json(trace: dict[str, Any]) -> dict[str, str]:
result = {}
for key in ("source_json", "payload_json", "result_json"):
raw = trace.get(key)
if raw:
result[key] = json.dumps(json.loads(raw), indent=2, sort_keys=True)
return result
def _search_items(
garmin: GarminService,
repo: Repository,
start: date,
end: date,
sport: str,
q: str,
source: str,
) -> list[dict[str, Any]]:
if end < start:
raise ValueError("End date must be on or after start date")
sport_filter = sport.strip().lower()
text_filter = q.strip().lower()
allowed_sources = {"all", "calendar", "workouts", "plans", "coach", "cloned"}
if source not in allowed_sources:
source = "all"
items: list[dict[str, Any]] = []
client = garmin.authenticated_client()
if source in {"all", "calendar"}:
items.extend(_calendar_search_items(client, start, end))
if source in {"all", "workouts"}:
items.extend(_workout_search_items(client))
if source in {"all", "plans", "coach"}:
plans = extract_training_plans(client.get_training_plans())
if source in {"all", "plans"}:
items.extend(_plan_search_items(plans))
if source in {"all", "coach"}:
items.extend(_coach_search_items(garmin, client, plans, start, end))
if source in {"all", "cloned"}:
items.extend(_clone_search_items(repo))
return [
item
for item in items
if _matches_search_item(item, sport_filter, text_filter, start, end)
]
def _calendar_search_items(client: Any, start: date, end: date) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
for month in _months_between(start, end):
data = client.get_scheduled_workouts(month.year, month.month)
_walk_calendar(data, items)
return items
def _walk_calendar(node: Any, items: list[dict[str, Any]]) -> None:
if isinstance(node, dict):
name = calendar_entry_name(node)
item_date = calendar_entry_date(node)
if name and item_date:
items.append(
{
"source": "calendar",
"date": item_date,
"sport": _sport_key(node),
"name": name,
"status": "scheduled",
"summary": "",
}
)
for child in node.values():
_walk_calendar(child, items)
elif isinstance(node, list):
for child in node:
_walk_calendar(child, items)
def _workout_search_items(client: Any) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
for workout in client.get_workouts(limit=100):
if not isinstance(workout, dict):
continue
items.append(
{
"source": "workouts",
"date": "",
"sport": _sport_key(workout),
"name": workout.get("workoutName") or workout.get("name") or "<unnamed>",
"status": str(workout.get("workoutId") or workout.get("id") or ""),
"summary": summarize_workout(workout) if workout.get("workoutSegments") else "",
}
)
return items
def _plan_search_items(plans: list[dict[str, Any]]) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
for plan in plans:
items.append(
{
"source": "plans",
"date": "",
"sport": str((plan.get("trainingType") or {}).get("typeKey") or ""),
"name": plan.get("name") or "<unnamed plan>",
"status": plan.get("trainingPlanCategory") or "",
"summary": f"Plan ID: {plan.get('trainingPlanId') or plan.get('id') or '-'}",
}
)
return items
def _coach_search_items(
garmin: GarminService,
client: Any,
plans: list[dict[str, Any]],
start: date,
end: date,
) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
for plan in plans:
if not is_adaptive_plan(plan):
continue
detail = garmin.adaptive_plan_detail(client, plan)
for target_date in _dates_between(start, end):
task = task_for_date(detail, target_date)
if task is None:
continue
workout = task.get("taskWorkout")
name = "<rest day>"
status = "rest"
uuid = ""
if isinstance(workout, dict):
name = str(workout.get("workoutName") or workout.get("name") or "Coach Workout")
uuid = str(workout.get("workoutUuid") or "")
status = "workout" if uuid else "task"
items.append(
{
"source": "coach",
"date": target_date.isoformat(),
"sport": "cycling",
"name": name,
"status": status,
"summary": uuid,
}
)
return items
def _clone_search_items(repo: Repository) -> list[dict[str, Any]]:
return [
{
"source": "cloned",
"date": str(clone.get("scheduled_date") or ""),
"sport": "cycling",
"name": clone.get("clone_workout_name") or clone.get("source_name") or "<unnamed>",
"status": clone.get("status") or "",
"summary": clone.get("message") or "",
}
for clone in repo.list_clone_mappings(limit=100)
]
def _matches_search_item(
item: dict[str, Any],
sport_filter: str,
text_filter: str,
start: date,
end: date,
) -> bool:
item_date = str(item.get("date") or "")
if item_date:
parsed = date.fromisoformat(item_date[:10])
if parsed < start or parsed > end:
return False
if sport_filter and sport_filter not in str(item.get("sport") or "").lower():
return False
haystack = " ".join(
str(item.get(key) or "") for key in ("source", "name", "status", "summary")
).lower()
return not text_filter or text_filter in haystack
def _months_between(start: date, end: date) -> list[date]:
cursor = date(start.year, start.month, 1)
last = date(end.year, end.month, 1)
months = []
while cursor <= last:
months.append(cursor)
if cursor.month == 12:
cursor = date(cursor.year + 1, 1, 1)
else:
cursor = date(cursor.year, cursor.month + 1, 1)
return months
def _dates_between(start: date, end: date) -> list[date]:
return [start + timedelta(days=offset) for offset in range((end - start).days + 1)]
def _sport_key(value: dict[str, Any]) -> str:
sport = value.get("sportType")
if isinstance(sport, dict):
return str(sport.get("sportTypeKey") or sport.get("typeKey") or "")
for nested_key in ("workout", "workoutDTO", "workoutSummary"):
nested = value.get(nested_key)
if isinstance(nested, dict):
found = _sport_key(nested)
if found:
return found
return str(value.get("sport") or "")
app = create_app()

View File

@ -0,0 +1,87 @@
from __future__ import annotations
import os
from getpass import getpass
from pathlib import Path
from typing import Any
from garminconnect import Garmin, GarminConnectAuthenticationError, GarminConnectConnectionError
def credentials_from_env_or_prompt() -> tuple[str, str]:
email = os.getenv("GARMIN_EMAIL") or input("Garmin email: ").strip()
password = os.getenv("GARMIN_PASSWORD") or getpass("Garmin password: ")
if not email or not password:
raise GarminConnectAuthenticationError("Garmin email/password are required")
return email, password
def token_store_path() -> Path:
return Path(os.getenv("GARMIN_TOKEN_STORE", ".garmin-tokens")).expanduser()
def _dump_tokens(api: Garmin, tokenstore: Path) -> None:
tokenstore.mkdir(parents=True, exist_ok=True)
dump = getattr(getattr(api, "client", None), "dump", None)
if callable(dump):
dump(str(tokenstore))
def login(interactive: bool = True) -> Garmin:
tokenstore = token_store_path()
try:
api = Garmin()
api.login(str(tokenstore))
return api
except (FileNotFoundError, GarminConnectAuthenticationError, GarminConnectConnectionError):
pass
email, password = credentials_from_env_or_prompt()
api = Garmin(email=email, password=password, is_cn=False, return_on_mfa=True)
result, state = api.login()
if result == "needs_mfa":
if not interactive:
raise GarminConnectAuthenticationError("MFA required but interactive input is disabled")
code = getpass("Garmin MFA code: ").strip()
if not code:
raise GarminConnectAuthenticationError("MFA code is required")
api.resume_login(state if isinstance(state, dict) else {}, code)
_dump_tokens(api, tokenstore)
# Re-open through the normal path so profile/settings loading follows the package path.
verified = Garmin(email=email, password=password, is_cn=False)
verified.login(str(tokenstore))
return verified
def account_summary(api: Garmin) -> dict[str, Any]:
summary: dict[str, Any] = {
"full_name": _safe_call(api.get_full_name),
"unit_system": _safe_call(api.get_unit_system),
}
devices = _safe_call(api.get_devices)
if isinstance(devices, list):
summary["devices"] = [
{
"displayName": device.get("displayName"),
"deviceType": device.get("deviceType"),
"softwareVersion": device.get("softwareVersion"),
"unitId": device.get("unitId"),
}
for device in devices
if isinstance(device, dict)
]
else:
summary["devices_error"] = devices
summary["last_used_device"] = _safe_call(api.get_device_last_used)
return summary
def _safe_call(func: Any) -> Any:
try:
return func()
except Exception as exc: # noqa: BLE001 - displayed as probe output
return f"<unavailable: {exc}>"

View File

@ -0,0 +1,197 @@
from __future__ import annotations
import os
from collections.abc import Iterable
from datetime import date
from typing import Any
from garminconnect import GarminConnectConnectionError
def extract_training_plans(response: Any) -> list[dict[str, Any]]:
if isinstance(response, dict):
plans = response.get("trainingPlanList")
if isinstance(plans, list):
return [plan for plan in plans if isinstance(plan, dict)]
if isinstance(response, list):
return [plan for plan in response if isinstance(plan, dict)]
return []
def is_adaptive_plan(plan: dict[str, Any]) -> bool:
category = str(plan.get("trainingPlanCategory", "")).upper()
name = str(plan.get("name", "")).lower()
return category == "FBT_ADAPTIVE" or "coach" in name or "adaptive" in name
def plan_id(plan: dict[str, Any]) -> int | None:
for key in ("trainingPlanId", "planId", "id"):
value = plan.get(key)
if value is not None:
try:
return int(value)
except (TypeError, ValueError):
pass
return None
def find_date_matches(value: Any, target_date: date) -> list[dict[str, Any]]:
needle = target_date.isoformat()
matches: list[dict[str, Any]] = []
def walk(node: Any, ancestors: list[dict[str, Any]]) -> None:
if isinstance(node, dict):
next_ancestors = [*ancestors, node]
if _contains_date(node, needle):
matches.append(node)
for child in node.values():
walk(child, next_ancestors)
elif isinstance(node, list):
for child in node:
walk(child, ancestors)
walk(value, [])
return _dedupe_dicts(matches)
def find_workout_like_objects(value: Any) -> list[dict[str, Any]]:
found: list[dict[str, Any]] = []
def walk(node: Any) -> None:
if isinstance(node, dict):
if isinstance(node.get("workoutSegments"), list) or isinstance(
node.get("workoutSteps"), list
):
found.append(node)
for child in node.values():
walk(child)
elif isinstance(node, list):
for child in node:
walk(child)
walk(value)
return _dedupe_dicts(found)
def best_workout_for_date(value: Any, target_date: date) -> dict[str, Any] | None:
date_matches = find_date_matches(value, target_date)
for match in date_matches:
if isinstance(match.get("workoutSegments"), list):
return match
nested = find_workout_like_objects(match)
if nested:
return nested[0]
return None
def task_for_date(plan_detail: Any, target_date: date) -> dict[str, Any] | None:
if not isinstance(plan_detail, dict):
return None
tasks = plan_detail.get("taskList")
if not isinstance(tasks, list):
return None
for task in tasks:
if not isinstance(task, dict):
continue
if str(task.get("calendarDate", ""))[:10] == target_date.isoformat():
return task
return None
def task_workout_for_date(plan_detail: Any, target_date: date) -> dict[str, Any] | None:
task = task_for_date(plan_detail, target_date)
if task is None:
return None
workout = task.get("taskWorkout")
return workout if isinstance(workout, dict) else None
def coach_workout_uuid_for_date(plan_detail: Any, target_date: date) -> str | None:
workout = task_workout_for_date(plan_detail, target_date)
if workout is None:
return None
uuid = workout.get("workoutUuid")
return str(uuid) if uuid else None
def get_fbt_adaptive_workout(api: Any, workout_uuid: str) -> dict[str, Any]:
response = api.connectapi(f"/workout-service/fbt-adaptive/{workout_uuid}")
if not isinstance(response, dict):
raise GarminConnectConnectionError(
f"FBT adaptive workout endpoint returned {type(response).__name__}, expected object"
)
return response
def describe_plans(plans: Iterable[dict[str, Any]]) -> str:
lines: list[str] = []
for plan in plans:
lines.append(
" - id={id} category={category} name={name}".format(
id=plan.get("trainingPlanId") or plan.get("planId") or plan.get("id"),
category=plan.get("trainingPlanCategory"),
name=plan.get("name"),
)
)
return "\n".join(lines) if lines else "No training plans returned."
def _contains_date(node: dict[str, Any], needle: str) -> bool:
return any(needle in str(key) or needle in str(value) for key, value in node.items())
def _dedupe_dicts(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
seen: set[int] = set()
deduped: list[dict[str, Any]] = []
for item in items:
marker = id(item)
if marker not in seen:
seen.add(marker)
deduped.append(item)
return deduped
def gc_api_get(api: Any, path: str) -> Any:
client = getattr(api, "client", None)
session = getattr(client, "_api_session", None)
if client is None or session is None:
raise GarminConnectConnectionError("Garmin client does not expose an API session")
url = f"https://connect.garmin.com/{path.lstrip('/')}"
env_cookie = os.getenv("GARMIN_GC_API_COOKIE")
if env_cookie:
env_headers = {
"Accept": "application/json",
"NK": "NT",
"Origin": "https://connect.garmin.com",
"Referer": "https://connect.garmin.com/modern/",
"Cookie": env_cookie,
}
env_csrf = os.getenv("GARMIN_GC_API_CSRF")
if env_csrf:
env_headers["connect-csrf-token"] = env_csrf
response = session.get(url, headers=env_headers, timeout=15)
if response.status_code < 400:
return response.json()
headers = dict(client.get_api_headers())
response = session.get(url, headers=headers, timeout=15)
if response.status_code == 403 and getattr(client, "jwt_web", None):
web_headers = {
"Accept": "application/json",
"NK": "NT",
"Origin": "https://connect.garmin.com",
"Referer": "https://connect.garmin.com/modern/",
"Cookie": f"JWT_WEB={client.jwt_web}",
}
csrf_token = getattr(client, "csrf_token", None)
if csrf_token:
web_headers["connect-csrf-token"] = str(csrf_token)
response = session.get(url, headers=web_headers, timeout=15)
if response.status_code >= 400:
message = f"GC API Error {response.status_code}"
if len(response.text) < 500:
message += f" - {response.text}"
raise GarminConnectConnectionError(message)
return response.json()

View File

@ -0,0 +1,92 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
DEFAULT_FIXED_TIMES = [
"05:15",
"06:15",
"07:15",
"08:15",
"09:15",
"10:15",
"11:15",
"12:15",
"13:15",
]
DEFAULT_ACTIVE_WINDOW = "05:00-22:00"
DEFAULT_INTERVAL_MINUTES = 30
def _project_root() -> Path:
return Path(__file__).resolve().parents[2]
@dataclass(frozen=True)
class Settings:
data_dir: Path
db_path: Path
token_dir: Path
log_dir: Path
debug_dir: Path
static_dir: Path
template_dir: Path
app_username: str
app_password: str
app_secret_key: str
timezone: str
clone_prefix: str
sync_enabled: bool
sync_days_ahead: int
overwrite_existing: bool
delete_old_clones: bool
change_interval_minutes: int
change_active_window: str
change_fixed_times: list[str]
log_level: str
debug_dump_json: bool
session_cookie: str = "gcc_session"
session_ttl_seconds: int = 60 * 60 * 24 * 30
def load_settings() -> Settings:
data_dir = Path(os.getenv("DATA_DIR", "/data")).expanduser()
if not os.access(str(data_dir.parent), os.W_OK):
data_dir = _project_root() / "data"
fixed = os.getenv("CHANGE_DETECTION_FIXED_TIMES", ",".join(DEFAULT_FIXED_TIMES))
return Settings(
data_dir=data_dir,
db_path=Path(os.getenv("DB_PATH", str(data_dir / "app.db"))).expanduser(),
token_dir=Path(os.getenv("GARMIN_TOKEN_DIR", str(data_dir / "garmin-tokens"))).expanduser(),
log_dir=Path(os.getenv("LOG_DIR", str(data_dir / "logs"))).expanduser(),
debug_dir=Path(os.getenv("DEBUG_DIR", str(data_dir / "debug"))).expanduser(),
static_dir=Path(os.getenv("STATIC_DIR", str(_project_root() / "static"))).expanduser(),
template_dir=Path(
os.getenv("TEMPLATE_DIR", str(_project_root() / "templates"))
).expanduser(),
app_username=os.getenv("APP_USERNAME", "admin"),
app_password=os.getenv("APP_PASSWORD", "change-me"),
app_secret_key=os.getenv("APP_SECRET_KEY", "replace-with-long-random-secret"),
timezone=os.getenv("TZ", "Europe/Berlin"),
clone_prefix=os.getenv("CLONE_PREFIX", "GCClone"),
sync_enabled=_bool_env("SYNC_ENABLED", True),
sync_days_ahead=max(0, int(os.getenv("SYNC_DAYS_AHEAD", "1"))),
overwrite_existing=_bool_env("OVERWRITE_EXISTING", True),
delete_old_clones=_bool_env("DELETE_OLD_CLONES", False),
change_interval_minutes=max(
5, int(os.getenv("CHANGE_DETECTION_INTERVAL_MINUTES", str(DEFAULT_INTERVAL_MINUTES)))
),
change_active_window=os.getenv("CHANGE_DETECTION_ACTIVE_WINDOW", DEFAULT_ACTIVE_WINDOW),
change_fixed_times=[item.strip() for item in fixed.split(",") if item.strip()],
log_level=os.getenv("LOG_LEVEL", "INFO"),
debug_dump_json=_bool_env("DEBUG_DUMP_JSON", False),
)
def _bool_env(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}

View File

@ -0,0 +1,73 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
from typing import Any
from cryptography.fernet import Fernet
def _fernet_key(secret: str) -> bytes:
digest = hashlib.sha256(secret.encode("utf-8")).digest()
return base64.urlsafe_b64encode(digest)
class Crypto:
def __init__(self, secret: str) -> None:
self._secret = secret.encode("utf-8")
self._fernet = Fernet(_fernet_key(secret))
def encrypt(self, value: str) -> str:
return self._fernet.encrypt(value.encode("utf-8")).decode("ascii")
def decrypt(self, value: str) -> str:
return self._fernet.decrypt(value.encode("ascii")).decode("utf-8")
def hash_password(self, password: str) -> str:
salt = os.urandom(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 210_000)
return "pbkdf2_sha256${}${}".format(
base64.urlsafe_b64encode(salt).decode("ascii"),
base64.urlsafe_b64encode(digest).decode("ascii"),
)
def verify_password(self, password: str, encoded: str) -> bool:
try:
scheme, salt_s, digest_s = encoded.split("$", 2)
except ValueError:
return False
if scheme != "pbkdf2_sha256":
return False
salt = base64.urlsafe_b64decode(salt_s.encode("ascii"))
expected = base64.urlsafe_b64decode(digest_s.encode("ascii"))
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 210_000)
return hmac.compare_digest(expected, actual)
def sign_session(self, payload: dict[str, Any], ttl_seconds: int) -> str:
body = dict(payload)
body["exp"] = int(time.time()) + ttl_seconds
encoded = base64.urlsafe_b64encode(
json.dumps(body, separators=(",", ":"), sort_keys=True).encode("utf-8")
).decode("ascii")
sig = hmac.new(self._secret, encoded.encode("ascii"), hashlib.sha256).hexdigest()
return f"{encoded}.{sig}"
def verify_session(self, token: str) -> dict[str, Any] | None:
try:
encoded, sig = token.split(".", 1)
except ValueError:
return None
expected = hmac.new(self._secret, encoded.encode("ascii"), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
return None
try:
payload = json.loads(base64.urlsafe_b64decode(encoded.encode("ascii")))
except (ValueError, json.JSONDecodeError):
return None
if int(payload.get("exp", 0)) < int(time.time()):
return None
return payload if isinstance(payload, dict) else None

View File

@ -0,0 +1,12 @@
from __future__ import annotations
from datetime import date, timedelta
def parse_date(value: str | None) -> date:
if value in (None, "", "today"):
return date.today()
if value == "tomorrow":
return date.today() + timedelta(days=1)
return date.fromisoformat(value)

View File

@ -0,0 +1,112 @@
from __future__ import annotations
import sqlite3
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any
SCHEMA = """
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS clone_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scheduled_date TEXT NOT NULL,
source_uuid TEXT,
source_hash TEXT,
source_name TEXT,
clone_workout_id TEXT,
clone_workout_name TEXT,
scheduled_workout_id TEXT,
status TEXT NOT NULL,
message TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(scheduled_date)
);
CREATE TABLE IF NOT EXISTS sync_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
trigger TEXT NOT NULL,
status TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TEXT,
created_count INTEGER NOT NULL DEFAULT 0,
skipped_count INTEGER NOT NULL DEFAULT 0,
replaced_count INTEGER NOT NULL DEFAULT 0,
warning_count INTEGER NOT NULL DEFAULT 0,
error TEXT
);
CREATE TABLE IF NOT EXISTS sync_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER REFERENCES sync_runs(id) ON DELETE CASCADE,
level TEXT NOT NULL,
event_type TEXT NOT NULL,
scheduled_date TEXT,
message TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
details_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_sync_events_run_id ON sync_events(run_id);
CREATE INDEX IF NOT EXISTS idx_sync_events_date ON sync_events(scheduled_date);
CREATE TABLE IF NOT EXISTS workout_traces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER REFERENCES sync_runs(id) ON DELETE SET NULL,
scheduled_date TEXT NOT NULL,
source_uuid TEXT,
source_hash TEXT,
source_name TEXT,
clone_workout_name TEXT,
action TEXT NOT NULL,
status TEXT NOT NULL,
message TEXT,
source_json TEXT,
payload_json TEXT,
result_json TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_workout_traces_date ON workout_traces(scheduled_date);
"""
class Database:
def __init__(self, path: Path) -> None:
self.path = path
def initialize(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
with self.connect() as conn:
conn.executescript(SCHEMA)
@contextmanager
def connect(self) -> Iterator[sqlite3.Connection]:
conn = sqlite3.connect(self.path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
return None if row is None else dict(row)
def rows_to_dicts(rows: list[sqlite3.Row]) -> list[dict[str, Any]]:
return [dict(row) for row in rows]

View File

@ -0,0 +1,120 @@
from __future__ import annotations
import contextlib
from dataclasses import dataclass
from datetime import date
from typing import Any
from garminconnect import Garmin
from .coach import (
extract_training_plans,
get_fbt_adaptive_workout,
is_adaptive_plan,
plan_id,
task_workout_for_date,
)
from .config import Settings
from .crypto import Crypto
from .repository import Repository
@dataclass(frozen=True)
class GarminCredentials:
email: str
password: str
class GarminService:
def __init__(self, settings: Settings, repo: Repository, crypto: Crypto) -> None:
self.settings = settings
self.repo = repo
self.crypto = crypto
self.settings.token_dir.mkdir(parents=True, exist_ok=True)
self._pending_mfa_client: Garmin | None = None
self._pending_mfa_state: dict[str, Any] = {}
def save_credentials(self, email: str, password: str) -> None:
self.repo.save_garmin_credentials(self.crypto.encrypt(email), self.crypto.encrypt(password))
def credentials(self) -> GarminCredentials | None:
encrypted = self.repo.encrypted_garmin_credentials()
if encrypted is None:
return None
return GarminCredentials(
email=self.crypto.decrypt(encrypted[0]),
password=self.crypto.decrypt(encrypted[1]),
)
def configured(self) -> bool:
return self.credentials() is not None
def setup_login(self) -> dict[str, Any]:
creds = self.credentials()
if creds is None:
raise ValueError("Garmin credentials are not configured")
client = Garmin(email=creds.email, password=creds.password, is_cn=False, return_on_mfa=True)
result, state = client.login()
if result == "needs_mfa":
self._pending_mfa_client = client
self._pending_mfa_state = state if isinstance(state, dict) else {}
self.repo.set_setting("garmin_mfa_state", "pending")
return {"status": "mfa_required"}
self._dump_tokens(client)
self.repo.set_setting("garmin_mfa_state", "")
return {"status": "ok"}
def resume_mfa(self, code: str) -> dict[str, Any]:
if self._pending_mfa_client is None:
raise ValueError("No Garmin MFA challenge is pending")
self._pending_mfa_client.resume_login(self._pending_mfa_state, code)
self._dump_tokens(self._pending_mfa_client)
self._pending_mfa_client = None
self._pending_mfa_state = {}
self.repo.set_setting("garmin_mfa_state", "")
return {"status": "ok"}
def authenticated_client(self) -> Garmin:
creds = self.credentials()
if creds is None:
raise ValueError("Garmin credentials are not configured")
client = Garmin(email=creds.email, password=creds.password, is_cn=False)
client.login(str(self.settings.token_dir))
return client
def active_adaptive_plan(self, client: Garmin) -> dict[str, Any] | None:
plans = extract_training_plans(client.get_training_plans())
for plan in plans:
sport = str((plan.get("trainingType") or {}).get("typeKey", "")).lower()
if is_adaptive_plan(plan) and (not sport or sport == "cycling"):
return plan
return None
def adaptive_plan_detail(self, client: Garmin, plan: dict[str, Any]) -> dict[str, Any]:
pid = plan_id(plan)
if pid is None:
raise ValueError("adaptive plan has no numeric ID")
return client.get_adaptive_training_plan_by_id(pid)
def coach_workout_for_date(
self, client: Garmin, plan_detail: dict[str, Any], target_date: date
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
task_workout = task_workout_for_date(plan_detail, target_date)
if task_workout is None:
return None, None
if task_workout.get("restDay"):
return task_workout, None
uuid = task_workout.get("workoutUuid")
if not uuid:
return task_workout, None
return task_workout, get_fbt_adaptive_workout(client, str(uuid))
def _dump_tokens(self, client: Garmin) -> None:
token_client = getattr(client, "client", None)
if token_client is None:
return
token_client._tokenstore_path = str(self.settings.token_dir)
dump = getattr(token_client, "dump", None)
if callable(dump):
with contextlib.suppress(Exception):
dump(str(self.settings.token_dir))

View File

@ -0,0 +1,17 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from .redact import redact
def dump_redacted_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(redact(data), indent=2, sort_keys=True), encoding="utf-8")
def print_json(data: Any) -> None:
print(json.dumps(redact(data), indent=2, sort_keys=True))

View File

@ -0,0 +1,71 @@
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any
EMAIL_RE = re.compile(r"[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}")
BEARER_RE = re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]+", re.IGNORECASE)
PRIVATE_EXACT_KEYS = {
"address",
"birthdate",
"dateofbirth",
"deviceid",
"displayname",
"email",
"firstname",
"fullname",
"garminuserid",
"lastname",
"mfacode",
"password",
"phonenumber",
"serialnumber",
"unitid",
"userid",
"username",
}
PRIVATE_KEY_PARTS = {
"access_token",
"authorization",
"cookie",
"homeaddress",
"oauth",
"owner",
"profile",
"refresh_token",
"secret",
"session",
"token",
}
def redact(value: Any) -> Any:
if isinstance(value, Mapping):
redacted: dict[str, Any] = {}
for key, item in value.items():
key_text = str(key).lower()
normalized = _normalize_key(key_text)
if normalized in PRIVATE_EXACT_KEYS or any(
part in key_text for part in PRIVATE_KEY_PARTS
):
redacted[str(key)] = "<redacted>"
else:
redacted[str(key)] = redact(item)
return redacted
if isinstance(value, list):
return [redact(item) for item in value]
if isinstance(value, str):
return _redact_string(value)
return value
def _normalize_key(value: str) -> str:
return re.sub(r"[^a-z0-9]", "", value)
def _redact_string(value: str) -> str:
redacted = EMAIL_RE.sub("<redacted-email>", value)
return BEARER_RE.sub("Bearer <redacted-token>", redacted)

View File

@ -0,0 +1,351 @@
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import Any
from .config import (
DEFAULT_ACTIVE_WINDOW,
DEFAULT_FIXED_TIMES,
DEFAULT_INTERVAL_MINUTES,
Settings,
)
from .db import Database, row_to_dict, rows_to_dicts
TIME_RE = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$")
@dataclass(frozen=True)
class ScheduleConfig:
enabled: bool
interval_minutes: int
active_window: str
fixed_times: list[str]
days_ahead: int
class Repository:
def __init__(self, db: Database, settings: Settings) -> None:
self.db = db
self.settings = settings
def get_setting(self, key: str) -> str | None:
with self.db.connect() as conn:
row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
return None if row is None else str(row["value"])
def set_setting(self, key: str, value: str) -> None:
with self.db.connect() as conn:
conn.execute(
"""
INSERT INTO settings(key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = CURRENT_TIMESTAMP
""",
(key, value),
)
def app_configured(self) -> bool:
return bool(self.get_setting("app_username") and self.get_setting("app_password_hash"))
def bootstrap_app_user(self, username: str, password_hash: str) -> None:
self.set_setting("app_username", username)
self.set_setting("app_password_hash", password_hash)
def app_username(self) -> str | None:
return self.get_setting("app_username")
def app_password_hash(self) -> str | None:
return self.get_setting("app_password_hash")
def garmin_configured(self) -> bool:
return bool(self.get_setting("garmin_email") and self.get_setting("garmin_password"))
def save_garmin_credentials(self, encrypted_email: str, encrypted_password: str) -> None:
self.set_setting("garmin_email", encrypted_email)
self.set_setting("garmin_password", encrypted_password)
def encrypted_garmin_credentials(self) -> tuple[str, str] | None:
email = self.get_setting("garmin_email")
password = self.get_setting("garmin_password")
if not email or not password:
return None
return email, password
def schedule_config(self) -> ScheduleConfig:
return ScheduleConfig(
enabled=_bool_setting(self.get_setting("sync_enabled"), self.settings.sync_enabled),
interval_minutes=int(
self.get_setting("change_interval_minutes")
or self.settings.change_interval_minutes
),
active_window=self.get_setting("change_active_window")
or self.settings.change_active_window,
fixed_times=_parse_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),
)
def save_schedule_config(self, config: ScheduleConfig) -> None:
validate_schedule_config(config)
self.set_setting("sync_enabled", "true" if config.enabled else "false")
self.set_setting("change_interval_minutes", str(config.interval_minutes))
self.set_setting("change_active_window", config.active_window)
self.set_setting("change_fixed_times", ",".join(config.fixed_times))
self.set_setting("sync_days_ahead", str(config.days_ahead))
def restore_default_schedule(self) -> ScheduleConfig:
config = ScheduleConfig(
enabled=self.settings.sync_enabled,
interval_minutes=DEFAULT_INTERVAL_MINUTES,
active_window=DEFAULT_ACTIVE_WINDOW,
fixed_times=DEFAULT_FIXED_TIMES.copy(),
days_ahead=self.settings.sync_days_ahead,
)
self.save_schedule_config(config)
return config
def start_sync_run(self, kind: str, trigger: str) -> int:
with self.db.connect() as conn:
cur = conn.execute(
"""
INSERT INTO sync_runs(kind, trigger, status)
VALUES (?, ?, 'running')
""",
(kind, trigger),
)
if cur.lastrowid is None:
raise RuntimeError("SQLite did not return a sync run id")
return int(cur.lastrowid)
def finish_sync_run(
self,
run_id: int,
status: str,
counts: dict[str, int],
error: str | None = None,
) -> None:
with self.db.connect() as conn:
conn.execute(
"""
UPDATE sync_runs
SET status = ?, completed_at = CURRENT_TIMESTAMP,
created_count = ?, skipped_count = ?, replaced_count = ?,
warning_count = ?, error = ?
WHERE id = ?
""",
(
status,
counts.get("created", 0),
counts.get("skipped", 0),
counts.get("replaced", 0),
counts.get("warnings", 0),
error,
run_id,
),
)
def add_event(
self,
run_id: int | None,
level: str,
event_type: str,
message: str,
scheduled_date: str | None = None,
details: dict[str, Any] | None = None,
) -> None:
with self.db.connect() as conn:
conn.execute(
"""
INSERT INTO sync_events(
run_id, level, event_type, scheduled_date, message, details_json
)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
run_id,
level,
event_type,
scheduled_date,
message,
None if details is None else _json(details),
),
)
def get_clone_mapping(self, scheduled_date: str) -> dict[str, Any] | None:
with self.db.connect() as conn:
row = conn.execute(
"SELECT * FROM clone_mappings WHERE scheduled_date = ?",
(scheduled_date,),
).fetchone()
return row_to_dict(row)
def upsert_clone_mapping(self, mapping: dict[str, Any]) -> None:
with self.db.connect() as conn:
conn.execute(
"""
INSERT INTO clone_mappings(
scheduled_date, source_uuid, source_hash, source_name,
clone_workout_id, clone_workout_name, scheduled_workout_id,
status, message, updated_at
)
VALUES (
:scheduled_date, :source_uuid, :source_hash, :source_name,
:clone_workout_id, :clone_workout_name, :scheduled_workout_id,
:status, :message, CURRENT_TIMESTAMP
)
ON CONFLICT(scheduled_date) DO UPDATE SET
source_uuid = excluded.source_uuid,
source_hash = excluded.source_hash,
source_name = excluded.source_name,
clone_workout_id = excluded.clone_workout_id,
clone_workout_name = excluded.clone_workout_name,
scheduled_workout_id = excluded.scheduled_workout_id,
status = excluded.status,
message = excluded.message,
updated_at = CURRENT_TIMESTAMP
""",
mapping,
)
def list_clone_mappings(self, limit: int = 50) -> list[dict[str, Any]]:
with self.db.connect() as conn:
return rows_to_dicts(
conn.execute(
"""
SELECT * FROM clone_mappings
ORDER BY scheduled_date DESC
LIMIT ?
""",
(limit,),
).fetchall()
)
def add_trace(self, trace: dict[str, Any]) -> int:
values = {
**trace,
"source_json": _json_or_none(trace.get("source_json")),
"payload_json": _json_or_none(trace.get("payload_json")),
"result_json": _json_or_none(trace.get("result_json")),
}
with self.db.connect() as conn:
cur = conn.execute(
"""
INSERT INTO workout_traces(
run_id, scheduled_date, source_uuid, source_hash, source_name,
clone_workout_name, action, status, message,
source_json, payload_json, result_json
)
VALUES (
:run_id, :scheduled_date, :source_uuid, :source_hash, :source_name,
:clone_workout_name, :action, :status, :message,
:source_json, :payload_json, :result_json
)
""",
values,
)
if cur.lastrowid is None:
raise RuntimeError("SQLite did not return a trace id")
return int(cur.lastrowid)
def list_traces(self, limit: int = 50) -> list[dict[str, Any]]:
with self.db.connect() as conn:
return rows_to_dicts(
conn.execute(
"""
SELECT id, run_id, scheduled_date, source_uuid, source_hash, source_name,
clone_workout_name, action, status, message, created_at
FROM workout_traces
ORDER BY created_at DESC, id DESC
LIMIT ?
""",
(limit,),
).fetchall()
)
def get_trace(self, trace_id: int) -> dict[str, Any] | None:
with self.db.connect() as conn:
row = conn.execute("SELECT * FROM workout_traces WHERE id = ?", (trace_id,)).fetchone()
return row_to_dict(row)
def list_events(self, limit: int = 100) -> list[dict[str, Any]]:
with self.db.connect() as conn:
return rows_to_dicts(
conn.execute(
"""
SELECT * FROM sync_events
ORDER BY created_at DESC, id DESC
LIMIT ?
""",
(limit,),
).fetchall()
)
def sync_status(self) -> dict[str, Any]:
with self.db.connect() as conn:
last = row_to_dict(
conn.execute(
"SELECT * FROM sync_runs ORDER BY started_at DESC, id DESC LIMIT 1"
).fetchone()
)
clones = rows_to_dicts(
conn.execute(
"""
SELECT * FROM clone_mappings
ORDER BY scheduled_date ASC
LIMIT 20
"""
).fetchall()
)
return {"last_run": last, "clones": clones, "schedule": self.schedule_config().__dict__}
def validate_schedule_config(config: ScheduleConfig) -> None:
if config.interval_minutes < 5 or config.interval_minutes > 24 * 60:
raise ValueError("interval must be between 5 and 1440 minutes")
start, end = _split_window(config.active_window)
if start >= end:
raise ValueError("active window start must be before end")
for value in config.fixed_times:
if not TIME_RE.match(value):
raise ValueError(f"invalid fixed time: {value}")
if config.days_ahead < 0 or config.days_ahead > 14:
raise ValueError("days ahead must be between 0 and 14")
def _split_window(value: str) -> tuple[str, str]:
parts = value.split("-", 1)
if len(parts) != 2 or not TIME_RE.match(parts[0]) or not TIME_RE.match(parts[1]):
raise ValueError("active window must be HH:MM-HH:MM")
return parts[0], parts[1]
def _parse_fixed_times(value: str | None, default: list[str]) -> list[str]:
if not value:
return default.copy()
cleaned = []
for item in value.split(","):
item = item.strip()
if item and item not in cleaned:
cleaned.append(item)
return cleaned
def _bool_setting(value: str | None, default: bool) -> bool:
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on"}
def _json(value: dict[str, Any]) -> str:
return json.dumps(value, separators=(",", ":"), sort_keys=True)
def _json_or_none(value: Any) -> str | None:
if value is None:
return None
return json.dumps(value, separators=(",", ":"), sort_keys=True)

View File

@ -0,0 +1,385 @@
from __future__ import annotations
import asyncio
from contextlib import suppress
from datetime import date, datetime, time, timedelta
from typing import Any, Protocol
from zoneinfo import ZoneInfo
from .config import Settings
from .io import redact
from .repository import Repository, ScheduleConfig
from .workouts import (
calendar_entry_id,
clone_workout_payload,
existing_clone_names,
generated_calendar_entries,
summarize_workout,
validate_workout_payload,
workout_source_hash,
)
class GarminLike(Protocol):
def authenticated_client(self) -> Any: ...
def active_adaptive_plan(self, client: Any) -> dict[str, Any] | None: ...
def adaptive_plan_detail(self, client: Any, plan: dict[str, Any]) -> dict[str, Any]: ...
def coach_workout_for_date(
self, client: Any, plan_detail: dict[str, Any], target_date: date
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: ...
class SyncService:
def __init__(self, settings: Settings, repo: Repository, garmin: GarminLike) -> None:
self.settings = settings
self.repo = repo
self.garmin = garmin
self._lock = asyncio.Lock()
self._stop = asyncio.Event()
self._task: asyncio.Task[Any] | None = None
self._last_run_key: str | None = None
def start(self) -> None:
self._task = asyncio.create_task(self._scheduler())
async def stop(self) -> None:
self._stop.set()
if self._task is not None:
self._task.cancel()
with suppress(asyncio.CancelledError):
await self._task
async def _scheduler(self) -> None:
await asyncio.sleep(10)
if self.repo.garmin_configured() and self.repo.schedule_config().enabled:
with _suppress_to_event(self.repo):
await self.run_change_detection("startup")
while not self._stop.is_set():
with suppress(TimeoutError):
await asyncio.wait_for(self._stop.wait(), timeout=60)
if self._stop.is_set():
break
config = self.repo.schedule_config()
if not config.enabled or not self.repo.garmin_configured():
continue
now = datetime.now(ZoneInfo(self.settings.timezone))
run_key = now.strftime("%Y-%m-%d %H:%M")
if run_key == self._last_run_key:
continue
if should_run_now(now, config):
self._last_run_key = run_key
with _suppress_to_event(self.repo):
await self.run_change_detection("schedule")
async def run_change_detection(
self,
trigger: str = "manual",
target_dates: list[date] | None = None,
dry_run: bool = False,
) -> dict[str, Any]:
async with self._lock:
return await asyncio.to_thread(self._run_sync, trigger, target_dates, dry_run)
def _run_sync(
self, trigger: str, target_dates: list[date] | None, dry_run: bool
) -> dict[str, Any]:
run_id = self.repo.start_sync_run("dry_run" if dry_run else "change_detection", trigger)
counts = {"created": 0, "skipped": 0, "replaced": 0, "warnings": 0}
status = "completed"
try:
client = self.garmin.authenticated_client()
plan = self.garmin.active_adaptive_plan(client)
if plan is None:
self.repo.add_event(
run_id,
"warning",
"no_plan",
"No active adaptive cycling plan found",
)
counts["warnings"] += 1
self.repo.finish_sync_run(run_id, status, counts)
return {"run_id": run_id, "status": status, **counts}
detail = self.garmin.adaptive_plan_detail(client, plan)
dates = target_dates or _default_dates(self.repo.schedule_config().days_ahead)
for target_date in dates:
decision = self._sync_date(run_id, client, detail, target_date, dry_run)
counts[decision] = counts.get(decision, 0) + 1
self.repo.finish_sync_run(run_id, status, counts)
return {"run_id": run_id, "status": status, **counts}
except Exception as exc:
status = "failed"
self.repo.add_event(run_id, "error", "sync_failed", str(exc))
self.repo.finish_sync_run(run_id, status, counts, str(exc))
raise
def _sync_date(
self,
run_id: int,
client: Any,
plan_detail: dict[str, Any],
target_date: date,
dry_run: bool,
) -> str:
date_s = target_date.isoformat()
task_workout, source = self.garmin.coach_workout_for_date(client, plan_detail, target_date)
mapping = self.repo.get_clone_mapping(date_s)
if task_workout is None:
return self._trace(
run_id, date_s, None, None, "missing", "warning", "No Coach task found"
)
if source is None:
if mapping and mapping.get("clone_workout_id"):
return self._trace(
run_id,
date_s,
task_workout,
None,
"rest_day_existing_clone",
"warning",
"Coach task is now rest/empty; existing clone was left untouched",
)
return self._trace(
run_id, date_s, task_workout, None, "rest_day", "skipped", "Rest day"
)
source_hash = workout_source_hash(source)
source_uuid = str(source.get("workoutUuid") or task_workout.get("workoutUuid") or "")
source_name = str(
source.get("workoutName") or task_workout.get("workoutName") or "Coach Workout"
)
payload = clone_workout_payload(source, target_date, self.settings.clone_prefix)
errors = validate_workout_payload(payload)
if errors:
return self._trace(
run_id,
date_s,
source,
payload,
"validate",
"warning",
"; ".join(errors),
source_hash=source_hash,
)
if (
mapping
and mapping.get("source_hash") == source_hash
and mapping.get("clone_workout_id")
):
return self._trace(
run_id,
date_s,
source,
payload,
"skip_unchanged",
"skipped",
"Generated clone is current",
source_hash=source_hash,
)
existing = existing_clone_names(
client.get_workouts(limit=100), target_date, self.settings.clone_prefix
)
if not mapping and existing:
self.repo.upsert_clone_mapping(
{
"scheduled_date": date_s,
"source_uuid": source_uuid,
"source_hash": source_hash,
"source_name": source_name,
"clone_workout_id": None,
"clone_workout_name": existing[0],
"scheduled_workout_id": None,
"status": "external_existing",
"message": "Generated clone exists in Garmin but is not mapped locally",
}
)
return self._trace(
run_id,
date_s,
source,
payload,
"external_existing",
"warning",
"Generated clone already exists in Garmin; skipped to avoid duplicate",
source_hash=source_hash,
)
action = "create" if mapping is None else "replace_changed"
if dry_run:
return self._trace(
run_id,
date_s,
source,
payload,
action,
"skipped",
f"Dry run would {action}",
source_hash=source_hash,
)
if mapping and mapping.get("clone_workout_id"):
self._replace_existing(client, mapping)
upload_result = client.upload_workout(payload)
workout_id = upload_result.get("workoutId") or upload_result.get("id")
if workout_id is None:
raise ValueError(f"Upload returned no workoutId: {upload_result}")
schedule_result = client.schedule_workout(workout_id, date_s)
scheduled_id = _scheduled_id(schedule_result) or self._find_scheduled_id(
client, payload, target_date
)
self.repo.upsert_clone_mapping(
{
"scheduled_date": date_s,
"source_uuid": source_uuid,
"source_hash": source_hash,
"source_name": source_name,
"clone_workout_id": str(workout_id),
"clone_workout_name": str(payload["workoutName"]),
"scheduled_workout_id": scheduled_id,
"status": "scheduled",
"message": summarize_workout(payload),
}
)
status_key = self._trace(
run_id,
date_s,
source,
payload,
action,
"completed",
f"Uploaded workout {workout_id} and scheduled it for {date_s}",
source_hash=source_hash,
result_json={"upload": upload_result, "schedule": schedule_result},
)
if action == "replace_changed":
return "replaced"
if status_key == "created":
return "created"
return status_key
def _replace_existing(self, client: Any, mapping: dict[str, Any]) -> None:
scheduled_id = mapping.get("scheduled_workout_id")
if scheduled_id:
client.unschedule_workout(str(scheduled_id))
if self.settings.delete_old_clones and mapping.get("clone_workout_id"):
client.delete_workout(str(mapping["clone_workout_id"]))
def _find_scheduled_id(
self, client: Any, payload: dict[str, Any], target_date: date
) -> str | None:
calendar = client.get_scheduled_workouts(target_date.year, target_date.month)
for entry in generated_calendar_entries(calendar, self.settings.clone_prefix):
if (
entry.get("workoutName") == payload["workoutName"]
or entry.get("title") == payload["workoutName"]
):
return calendar_entry_id(entry)
return None
def _trace(
self,
run_id: int,
scheduled_date: str,
source_json: dict[str, Any] | None,
payload_json: dict[str, Any] | None,
action: str,
status: str,
message: str,
source_hash: str | None = None,
result_json: dict[str, Any] | None = None,
) -> str:
source_name = None
source_uuid = None
if source_json:
source_name = source_json.get("workoutName") or source_json.get("name")
source_uuid = source_json.get("workoutUuid")
clone_name = payload_json.get("workoutName") if payload_json else None
self.repo.add_trace(
{
"run_id": run_id,
"scheduled_date": scheduled_date,
"source_uuid": source_uuid,
"source_hash": source_hash,
"source_name": source_name,
"clone_workout_name": clone_name,
"action": action,
"status": status,
"message": message,
"source_json": redact(source_json) if source_json else None,
"payload_json": redact(payload_json) if payload_json else None,
"result_json": redact(result_json) if result_json else None,
}
)
level = "warning" if status == "warning" else "info"
self.repo.add_event(run_id, level, action, message, scheduled_date)
if status == "warning":
return "warnings"
if action.startswith("skip") or status == "skipped":
return "skipped"
if action == "replace_changed":
return "replaced"
if action == "create":
return "created"
return "skipped"
def should_run_now(now: datetime, config: ScheduleConfig) -> bool:
current = now.strftime("%H:%M")
if current in config.fixed_times:
return True
start_s, end_s = config.active_window.split("-", 1)
start = _parse_time(start_s)
end = _parse_time(end_s)
current_t = now.time().replace(second=0, microsecond=0)
if not (start <= current_t <= end):
return False
minutes = now.hour * 60 + now.minute
return minutes % config.interval_minutes == 0
def next_run_after(now: datetime, config: ScheduleConfig) -> datetime | None:
if not config.enabled:
return None
probe = now.replace(second=0, microsecond=0) + timedelta(minutes=1)
for _ in range(60 * 48):
if should_run_now(probe, config):
return probe
probe += timedelta(minutes=1)
return None
def _default_dates(days_ahead: int) -> list[date]:
today = date.today()
return [today + timedelta(days=offset) for offset in range(days_ahead + 1)]
def _parse_time(value: str) -> time:
hour, minute = value.split(":", 1)
return time(int(hour), int(minute))
def _scheduled_id(value: Any) -> str | None:
if isinstance(value, dict):
for key in ("scheduledWorkoutId", "calendarItemId", "id"):
if value.get(key) is not None:
return str(value[key])
return None
class _suppress_to_event:
def __init__(self, repo: Repository) -> None:
self.repo = repo
def __enter__(self) -> None:
return None
def __exit__(self, _exc_type: Any, exc: BaseException | None, _tb: Any) -> bool:
if exc is not None:
self.repo.add_event(None, "error", "scheduler_error", str(exc))
return True

View File

@ -0,0 +1,43 @@
from __future__ import annotations
from fastapi import HTTPException, Request, Response
from .config import Settings
from .crypto import Crypto
def create_session(response: Response, settings: Settings, crypto: Crypto, username: str) -> None:
token = crypto.sign_session({"sub": username}, settings.session_ttl_seconds)
response.set_cookie(
settings.session_cookie,
token,
httponly=True,
samesite="lax",
secure=False,
max_age=settings.session_ttl_seconds,
path="/",
)
def clear_session(response: Response, settings: Settings) -> None:
response.delete_cookie(settings.session_cookie, path="/")
def current_user(request: Request) -> str | None:
settings: Settings = request.app.state.settings
crypto: Crypto = request.app.state.crypto
token = request.cookies.get(settings.session_cookie)
if not token:
return None
payload = crypto.verify_session(token)
if payload is None:
return None
subject = payload.get("sub")
return str(subject) if subject else None
def require_auth(request: Request) -> str:
user = current_user(request)
if user is None:
raise HTTPException(status_code=401, detail="Not authenticated")
return user

View File

@ -0,0 +1,354 @@
from __future__ import annotations
import hashlib
import json
from copy import deepcopy
from datetime import date, datetime
from typing import Any
from garminconnect.workout import (
CyclingWorkout,
WorkoutSegment,
create_cooldown_step,
create_interval_step,
create_recovery_step,
create_repeat_group,
create_warmup_step,
)
CYCLING_SPORT = {"sportTypeId": 2, "sportTypeKey": "cycling", "displayOrder": 2}
CLONE_ID_FIELDS = {
"author",
"createdDate",
"ownerId",
"shared",
"stepId",
"updatedDate",
"workoutId",
}
VALID_STEP_TYPES = {
"warmup",
"cooldown",
"interval",
"recovery",
"rest",
"repeat",
"other",
"main",
}
def build_dummy_cycling_workout(name: str | None = None) -> dict[str, Any]:
workout = CyclingWorkout(
workoutName=name or f"GCClone Probe Dummy {datetime.now():%Y-%m-%d %H:%M}",
description="Minimal cycling workout uploaded by the Garmin Coach clone probe.",
estimatedDurationInSecs=14 * 60,
workoutSegments=[
WorkoutSegment(
segmentOrder=1,
sportType=CYCLING_SPORT,
workoutSteps=[
create_warmup_step(5 * 60, step_order=1),
create_repeat_group(
iterations=1,
step_order=2,
workout_steps=[
create_interval_step(2 * 60, step_order=3),
create_recovery_step(2 * 60, step_order=4),
],
),
create_cooldown_step(5 * 60, step_order=5),
],
)
],
)
return workout.to_dict()
def clone_workout_payload(
source: dict[str, Any], scheduled_date: date, prefix: str
) -> dict[str, Any]:
if "workoutSegments" not in source:
raise ValueError("source object does not contain workoutSegments")
payload = deepcopy(source)
_strip_ids(payload)
original_name = str(
source.get("workoutName")
or source.get("name")
or source.get("title")
or "Garmin Coach Workout"
)
payload["workoutName"] = f"{prefix} {scheduled_date.isoformat()} {original_name}"[:120]
payload["description"] = (
"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["sportType"] = CYCLING_SPORT
for idx, segment in enumerate(payload.get("workoutSegments", []), start=1):
if isinstance(segment, dict):
segment["segmentOrder"] = segment.get("segmentOrder") or idx
segment["sportType"] = CYCLING_SPORT
payload.setdefault("estimatedDurationInSecs", estimate_duration(payload))
return payload
def validate_workout_payload(workout: dict[str, Any]) -> list[str]:
errors: list[str] = []
name = workout.get("workoutName")
if not isinstance(name, str) or not name.strip():
errors.append("workoutName must be a non-empty string")
sport_key = _key(workout.get("sportType"))
if sport_key != "cycling":
errors.append(f"sportType must be cycling, got {sport_key or '<missing>'}")
duration = workout.get("estimatedDurationInSecs")
if duration is not None and (not isinstance(duration, int) or duration < 0):
errors.append("estimatedDurationInSecs must be a non-negative integer when present")
segments = workout.get("workoutSegments")
if not isinstance(segments, list) or not segments:
errors.append("workoutSegments must be a non-empty list")
return errors
for segment_idx, segment in enumerate(segments, start=1):
path = f"workoutSegments[{segment_idx}]"
if not isinstance(segment, dict):
errors.append(f"{path} must be an object")
continue
if _key(segment.get("sportType")) != "cycling":
errors.append(f"{path}.sportType must be cycling")
steps = segment.get("workoutSteps")
if not isinstance(steps, list) or not steps:
errors.append(f"{path}.workoutSteps must be a non-empty list")
continue
_validate_steps(steps, f"{path}.workoutSteps", errors)
return errors
def estimate_duration(workout: dict[str, Any]) -> int:
total = 0
for segment in workout.get("workoutSegments", []):
if isinstance(segment, dict):
total += _steps_duration(segment.get("workoutSteps", []), multiplier=1)
return total
def workout_source_hash(workout: dict[str, Any]) -> str:
value = deepcopy(workout)
_strip_ids(value)
for key in (
"uploadTimestamp",
"workoutThumbnailUrl",
"sharedWithUsers",
"consumer",
"consumerImageURL",
"consumerName",
"consumerWebsiteURL",
):
value.pop(key, None)
encoded = json.dumps(value, separators=(",", ":"), sort_keys=True)
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def existing_clone_names(
workouts: list[dict[str, Any]], scheduled_date: date, prefix: str
) -> list[str]:
marker = f"{prefix} {scheduled_date.isoformat()}"
return [
str(workout.get("workoutName"))
for workout in workouts
if str(workout.get("workoutName", "")).startswith(marker)
]
def generated_workouts(workouts: list[dict[str, Any]], prefix: str) -> list[dict[str, Any]]:
return [
workout
for workout in workouts
if str(workout.get("workoutName", "")).startswith(prefix)
]
def find_generated_workout(
workouts: list[dict[str, Any]], workout_id: str, prefix: str
) -> dict[str, Any] | None:
for workout in generated_workouts(workouts, prefix):
if str(workout.get("workoutId") or workout.get("id")) == str(workout_id):
return workout
return None
def generated_calendar_entries(calendar_data: Any, prefix: str) -> list[dict[str, Any]]:
entries: list[dict[str, Any]] = []
def walk(node: Any) -> None:
if isinstance(node, dict):
name = calendar_entry_name(node)
if name is not None and name.startswith(prefix) and calendar_entry_id(node) is not None:
entries.append(node)
for child in node.values():
walk(child)
elif isinstance(node, list):
for child in node:
walk(child)
walk(calendar_data)
return _dedupe_calendar_entries(entries)
def find_generated_calendar_entry(
calendar_data: Any, scheduled_id: str, prefix: str
) -> dict[str, Any] | None:
for entry in generated_calendar_entries(calendar_data, prefix):
if calendar_entry_id(entry) == str(scheduled_id):
return entry
return None
def calendar_entry_id(entry: dict[str, Any]) -> str | None:
for key in ("scheduledWorkoutId", "calendarItemId"):
value = entry.get(key)
if value is not None:
return str(value)
return None
def calendar_entry_name(entry: dict[str, Any]) -> str | None:
for key in ("workoutName", "name", "title"):
value = entry.get(key)
if isinstance(value, str) and value:
return value
for key in ("workout", "workoutDTO", "workoutSummary"):
nested = entry.get(key)
if isinstance(nested, dict):
name = calendar_entry_name(nested)
if name is not None:
return name
return None
def calendar_entry_date(entry: dict[str, Any]) -> str | None:
for key in ("date", "startDate", "scheduledDate", "calendarDate"):
value = entry.get(key)
if isinstance(value, str) and value:
return value[:10]
return None
def summarize_workout(workout: dict[str, Any]) -> str:
lines: list[str] = []
name = workout.get("workoutName") or workout.get("name") or workout.get("title") or "<unnamed>"
lines.append(f"Workout: {name}")
for segment in workout.get("workoutSegments", []):
if not isinstance(segment, dict):
continue
segment_order = segment.get("segmentOrder", "?")
lines.append(f" Segment {segment_order}:")
_summarize_steps(lines, segment.get("workoutSteps", []), indent=" ")
if len(lines) == 1:
lines.append(" No workoutSegments/workoutSteps found.")
return "\n".join(lines)
def _summarize_steps(lines: list[str], steps: Any, indent: str) -> None:
if not isinstance(steps, list):
return
for step in steps:
if not isinstance(step, dict):
continue
step_type = _key(step.get("stepType")) or step.get("type") or "step"
if str(step.get("type")) == "RepeatGroupDTO" or step_type == "repeat":
iterations = step.get("numberOfIterations") or step.get("endConditionValue") or "?"
lines.append(f"{indent}repeat x{iterations}")
_summarize_steps(lines, step.get("workoutSteps", []), indent=f"{indent} ")
continue
end = _key(step.get("endCondition")) or "unknown-end"
end_value = step.get("endConditionValue")
target = _key(step.get("targetType")) or "no target"
lines.append(f"{indent}{step_type}: {end} {end_value}; target {target}")
def _key(value: Any) -> str | None:
if isinstance(value, dict):
for key in ("stepTypeKey", "conditionTypeKey", "workoutTargetTypeKey", "sportTypeKey"):
if value.get(key):
return str(value[key])
return None
def _steps_duration(steps: Any, multiplier: int) -> int:
if not isinstance(steps, list):
return 0
total = 0
for step in steps:
if not isinstance(step, dict):
continue
step_type = _key(step.get("stepType"))
if str(step.get("type")) == "RepeatGroupDTO" or step_type == "repeat":
iterations = int(step.get("numberOfIterations") or step.get("endConditionValue") or 1)
total += _steps_duration(step.get("workoutSteps", []), multiplier=iterations)
continue
end = _key(step.get("endCondition"))
if end == "time" and isinstance(step.get("endConditionValue"), int | float):
total += int(step["endConditionValue"]) * multiplier
return total
def _validate_steps(steps: list[Any], path: str, errors: list[str]) -> None:
for step_idx, step in enumerate(steps, start=1):
step_path = f"{path}[{step_idx}]"
if not isinstance(step, dict):
errors.append(f"{step_path} must be an object")
continue
step_type = _key(step.get("stepType"))
if step_type not in VALID_STEP_TYPES:
errors.append(f"{step_path}.stepType is invalid or missing: {step_type}")
continue
if step_type == "repeat" or str(step.get("type")) == "RepeatGroupDTO":
iterations = step.get("numberOfIterations") or step.get("endConditionValue")
if not isinstance(iterations, int | float) or iterations <= 0:
errors.append(f"{step_path}.numberOfIterations must be positive")
nested = step.get("workoutSteps")
if not isinstance(nested, list) or not nested:
errors.append(f"{step_path}.workoutSteps must be a non-empty list")
else:
_validate_steps(nested, f"{step_path}.workoutSteps", errors)
continue
end_condition = _key(step.get("endCondition"))
if end_condition is None:
errors.append(f"{step_path}.endCondition is missing")
elif end_condition != "lap.button":
end_value = step.get("endConditionValue")
if not isinstance(end_value, int | float) or end_value <= 0:
errors.append(f"{step_path}.endConditionValue must be positive")
if _key(step.get("targetType")) is None:
errors.append(f"{step_path}.targetType is missing")
def _strip_ids(value: Any) -> None:
if isinstance(value, dict):
for key in list(value.keys()):
if key in CLONE_ID_FIELDS:
value.pop(key, None)
else:
_strip_ids(value[key])
elif isinstance(value, list):
for item in value:
_strip_ids(item)
def _dedupe_calendar_entries(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
seen: set[str | int] = set()
deduped: list[dict[str, Any]] = []
for item in items:
marker: str | int = calendar_entry_id(item) or id(item)
if marker not in seen:
seen.add(marker)
deduped.append(item)
return deduped

373
static/style.css Normal file
View File

@ -0,0 +1,373 @@
:root {
--bg: #101312;
--panel: #191e1c;
--panel-2: #202823;
--line: #334139;
--text: #edf3ed;
--muted: #a5b4aa;
--accent: #d4f56a;
--accent-2: #49c6a2;
--danger: #ff6b57;
--warn: #e9b949;
--ok: #4ade80;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: "Aptos", "Segoe UI", sans-serif;
line-height: 1.45;
}
a {
color: var(--accent);
text-decoration: none;
}
.topbar {
align-items: center;
background: #0c0f0e;
border-bottom: 1px solid var(--line);
display: grid;
grid-template-columns: auto 1fr auto;
min-height: 64px;
padding: 0 24px;
position: sticky;
top: 0;
z-index: 3;
}
.brand {
align-items: center;
color: var(--text);
display: inline-flex;
font-weight: 800;
gap: 10px;
letter-spacing: .02em;
}
.brand-mark {
background: linear-gradient(135deg, var(--accent), var(--accent-2));
display: inline-block;
height: 18px;
transform: skew(-18deg);
width: 28px;
}
nav {
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: center;
}
nav a,
.icon-button,
.button,
button {
background: #253028;
border: 1px solid #3d4c42;
color: var(--text);
cursor: pointer;
display: inline-block;
font: inherit;
font-weight: 700;
padding: 10px 14px;
}
nav a:hover,
.icon-button:hover,
.button:hover,
button:hover {
border-color: var(--accent);
color: var(--accent);
}
.secondary {
background: transparent;
}
.shell {
margin: 0 auto;
max-width: 1180px;
padding: 32px 20px 64px;
}
.page-head {
align-items: end;
display: flex;
gap: 18px;
justify-content: space-between;
margin-bottom: 24px;
}
.eyebrow {
color: var(--accent-2);
font-size: .78rem;
font-weight: 800;
letter-spacing: .14em;
margin: 0 0 4px;
text-transform: uppercase;
}
h1,
h2 {
line-height: 1.05;
margin: 0;
}
h1 {
font-size: clamp(2rem, 5vw, 4.4rem);
}
h2 {
font-size: 1.1rem;
}
.metrics {
display: grid;
gap: 12px;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 18px;
}
.metrics article,
.panel,
.auth-panel {
background: var(--panel);
border: 1px solid var(--line);
}
.metrics article {
min-height: 98px;
padding: 16px;
}
.metrics span {
color: var(--muted);
display: block;
font-size: .78rem;
font-weight: 800;
text-transform: uppercase;
}
.metrics strong {
display: block;
font-size: 1.2rem;
margin-top: 12px;
overflow-wrap: anywhere;
}
.hash {
font-size: .78rem !important;
}
.panel {
margin-top: 18px;
overflow: hidden;
}
.panel > header {
align-items: center;
background: var(--panel-2);
border-bottom: 1px solid var(--line);
display: flex;
justify-content: space-between;
padding: 14px 16px;
}
.panel > p,
.form-panel form {
padding: 16px;
}
.muted,
.empty {
color: var(--muted);
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
border-bottom: 1px solid var(--line);
padding: 12px 14px;
text-align: left;
vertical-align: top;
}
th {
color: var(--muted);
font-size: .76rem;
text-transform: uppercase;
}
.badge,
.state {
border: 1px solid var(--line);
color: var(--accent);
display: inline-block;
font-size: .78rem;
font-weight: 800;
padding: 4px 8px;
text-transform: uppercase;
}
.badge.warning,
.state.warn {
color: var(--warn);
}
.badge.error {
color: var(--danger);
}
.state.ok {
color: var(--ok);
}
.stack {
display: grid;
gap: 14px;
}
.grid-form {
display: grid;
gap: 14px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
label {
color: var(--muted);
display: grid;
font-size: .82rem;
font-weight: 800;
gap: 6px;
text-transform: uppercase;
}
.checkbox {
align-items: center;
display: flex;
gap: 8px;
}
input,
select,
textarea {
background: #0e1211;
border: 1px solid var(--line);
color: var(--text);
font: inherit;
padding: 11px 12px;
width: 100%;
}
textarea {
resize: vertical;
}
.button-row,
.inline-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.button-row {
padding: 16px;
}
.alert,
.notice {
border: 1px solid;
font-weight: 700;
padding: 12px 14px;
}
.alert {
background: #2d1713;
border-color: #7f3529;
color: #ffb0a5;
}
.notice {
background: #15271e;
border-color: #2f6d48;
color: #a7f3c8;
}
pre {
background: #0b0e0d;
color: #d9e8da;
margin: 0;
max-height: 560px;
overflow: auto;
padding: 16px;
}
.inline-pre {
background: transparent;
max-height: 120px;
padding: 0;
white-space: pre-wrap;
}
.auth-page {
align-items: center;
display: grid;
min-height: 100vh;
padding: 20px;
}
.auth-panel {
margin: 0 auto;
max-width: 440px;
padding: 28px;
width: 100%;
}
.auth-brand {
margin-bottom: 28px;
}
.auth-panel h1 {
font-size: 2.6rem;
}
@media (max-width: 860px) {
.topbar,
.page-head {
align-items: stretch;
grid-template-columns: 1fr;
}
.topbar {
display: grid;
gap: 12px;
padding: 16px;
}
nav {
justify-content: flex-start;
}
.metrics,
.grid-form {
grid-template-columns: 1fr;
}
table {
display: block;
overflow-x: auto;
}
}

31
templates/base.html Normal file
View File

@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title or "Garmin Coach Clone" }}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="topbar">
<a class="brand" href="/">
<span class="brand-mark"></span>
<span>Coach Clone</span>
</a>
<nav>
<a href="/">Dashboard</a>
<a href="/garmin">Garmin</a>
<a href="/search">Search</a>
<a href="/schedule">Schedule</a>
<a href="/traces">Traces</a>
<a href="/logs">Logs</a>
</nav>
<form method="post" action="/logout">
<button class="icon-button" type="submit" title="Log out">Log out</button>
</form>
</header>
<main class="shell">
{% block content %}{% endblock %}
</main>
</body>
</html>

85
templates/dashboard.html Normal file
View File

@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block content %}
<section class="page-head">
<div>
<p class="eyebrow">Edge 1030 structured workout bridge</p>
<h1>Dashboard</h1>
</div>
<form method="post" action="/sync/run" class="inline-actions">
<button type="submit">Sync now</button>
<label class="checkbox"><input type="checkbox" name="dry_run"> Dry run</label>
</form>
</section>
<section class="metrics">
<article>
<span>Garmin</span>
<strong>{{ "Configured" if garmin_configured else "Needs setup" }}</strong>
</article>
<article>
<span>Last run</span>
<strong>{{ status.last_run.status if status.last_run else "Never" }}</strong>
</article>
<article>
<span>Next check</span>
<strong>{{ next_run.strftime("%Y-%m-%d %H:%M") if next_run else "Disabled" }}</strong>
</article>
<article>
<span>Window</span>
<strong>{{ schedule.active_window }}</strong>
</article>
</section>
<section class="panel">
<header>
<h2>Manual Checks</h2>
</header>
<div class="button-row">
<form method="post" action="/sync/run">
<input type="hidden" name="date_value" value="{{ today }}">
<button type="submit">Check today</button>
</form>
<form method="post" action="/sync/run">
<input type="hidden" name="date_value" value="{{ tomorrow }}">
<button type="submit">Check tomorrow</button>
</form>
<form method="post" action="/sync/run">
<input type="hidden" name="date_value" value="{{ today }}">
<input type="hidden" name="dry_run" value="on">
<button type="submit">Dry-run today</button>
</form>
<a class="button secondary" href="/search">Search workouts</a>
<a class="button secondary" href="/schedule">Edit schedule</a>
</div>
</section>
<section class="panel">
<header>
<h2>Clone State</h2>
</header>
<table>
<thead>
<tr>
<th>Date</th>
<th>Source</th>
<th>Clone</th>
<th>Status</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
{% for clone in status.clones %}
<tr>
<td>{{ clone.scheduled_date }}</td>
<td>{{ clone.source_name or "-" }}</td>
<td>{{ clone.clone_workout_name or "-" }}</td>
<td><span class="badge">{{ clone.status }}</span></td>
<td>{{ clone.updated_at }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="empty">No generated clones recorded yet.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

32
templates/garmin.html Normal file
View File

@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block content %}
<section class="page-head">
<div>
<p class="eyebrow">Garmin Connect</p>
<h1>Authentication</h1>
</div>
<span class="state {{ 'ok' if configured else 'warn' }}">{{ "Configured" if configured else "Not configured" }}</span>
</section>
{% if error %}<p class="alert">{{ error }}</p>{% endif %}
{% if message %}<p class="notice">{{ message }}</p>{% endif %}
<section class="panel form-panel">
<header><h2>Garmin Login</h2></header>
<form method="post" action="/garmin/setup" class="grid-form">
<label>Email <input name="email" type="email" autocomplete="email" required></label>
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">Save and log in</button>
</form>
</section>
{% if mfa_pending %}
<section class="panel form-panel">
<header><h2>MFA Challenge</h2></header>
<form method="post" action="/garmin/mfa" class="grid-form">
<label>Code <input name="code" inputmode="numeric" autocomplete="one-time-code" required></label>
<button type="submit">Submit MFA</button>
</form>
</section>
{% endif %}
{% endblock %}

22
templates/login.html Normal file
View File

@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Log In</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="auth-page">
<main class="auth-panel">
<div class="brand auth-brand"><span class="brand-mark"></span><span>Coach Clone</span></div>
<h1>Sign In</h1>
<p>Local single-user access for Garmin Coach cloning.</p>
{% if error %}<p class="alert">{{ error }}</p>{% endif %}
<form method="post" action="/login" class="stack">
<label>Username <input name="username" autocomplete="username" required></label>
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">Log in</button>
</form>
</main>
</body>
</html>

36
templates/logs.html Normal file
View File

@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block content %}
<section class="page-head">
<div>
<p class="eyebrow">Runtime trail</p>
<h1>Logs</h1>
</div>
</section>
<section class="panel">
<table>
<thead>
<tr>
<th>Time</th>
<th>Level</th>
<th>Event</th>
<th>Date</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{% for event in events %}
<tr>
<td>{{ event.created_at }}</td>
<td><span class="badge {{ event.level }}">{{ event.level }}</span></td>
<td>{{ event.event_type }}</td>
<td>{{ event.scheduled_date or "-" }}</td>
<td>{{ event.message }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="empty">No log events yet.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

47
templates/schedule.html Normal file
View File

@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block content %}
<section class="page-head">
<div>
<p class="eyebrow">Change detection</p>
<h1>Schedule</h1>
</div>
<form method="post" action="/schedule/restore">
<button type="submit" class="secondary">Restore defaults</button>
</form>
</section>
{% if error %}<p class="alert">{{ error }}</p>{% endif %}
{% if message %}<p class="notice">{{ message }}</p>{% endif %}
<section class="panel form-panel">
<header>
<h2>Sync Cadence</h2>
</header>
<form method="post" action="/schedule" class="stack">
<label class="checkbox">
<input type="checkbox" name="enabled" {% if schedule.enabled %}checked{% endif %}>
Sync enabled
</label>
<div class="grid-form">
<label>Interval minutes
<input name="interval_minutes" type="number" min="5" max="1440" value="{{ schedule.interval_minutes }}" required>
</label>
<label>Active window
<input name="active_window" pattern="^([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]$" value="{{ schedule.active_window }}" required>
</label>
<label>Days ahead
<input name="days_ahead" type="number" min="0" max="14" value="{{ schedule.days_ahead }}" required>
</label>
</div>
<label>Fixed check times
<textarea name="fixed_times" rows="3" required>{{ schedule.fixed_times | join(",") }}</textarea>
</label>
<button type="submit">Save schedule</button>
</form>
</section>
<section class="panel">
<header><h2>Default Behavior</h2></header>
<p class="muted">All scheduled runs are change-detection runs. They create a missing clone, skip a current clone, and replace only when the Coach source hash changes.</p>
</section>
{% endblock %}

74
templates/search.html Normal file
View File

@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block content %}
<section class="page-head">
<div>
<p class="eyebrow">Garmin Connect</p>
<h1>Search</h1>
</div>
</section>
{% if error %}<p class="alert">{{ error }}</p>{% endif %}
<section class="panel form-panel">
<header><h2>Filters</h2></header>
<form method="get" action="/search" class="grid-form">
<label>Start
<input name="start" type="date" value="{{ filters.start }}" required>
</label>
<label>End
<input name="end" type="date" value="{{ filters.end }}" required>
</label>
<label>Sport
<input name="sport" value="{{ filters.sport }}" placeholder="cycling">
</label>
<label>Text
<input name="q" value="{{ filters.q }}" placeholder="Sprint, GCClone, plan">
</label>
<label>Source
<select name="source">
{% for value, label in [
("all", "All"),
("calendar", "Calendar"),
("workouts", "Normal workouts"),
("plans", "Plans"),
("coach", "Coach"),
("cloned", "Cloned")
] %}
<option value="{{ value }}" {% if filters.source == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Search</button>
</form>
</section>
<section class="panel">
<header><h2>Results</h2></header>
<table>
<thead>
<tr>
<th>Source</th>
<th>Date</th>
<th>Sport</th>
<th>Name</th>
<th>Status</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td><span class="badge">{{ item.source }}</span></td>
<td>{{ item.date or "-" }}</td>
<td>{{ item.sport or "-" }}</td>
<td>{{ item.name }}</td>
<td>{{ item.status or "-" }}</td>
<td><pre class="inline-pre">{{ item.summary or "" }}</pre></td>
</tr>
{% else %}
<tr><td colspan="6" class="empty">No matching Garmin items found.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

23
templates/setup.html Normal file
View File

@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Set Up Coach Clone</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="auth-page">
<main class="auth-panel">
<div class="brand auth-brand"><span class="brand-mark"></span><span>Coach Clone</span></div>
<h1>First Launch</h1>
<p>Create the local app login. Garmin credentials are configured after this.</p>
{% if error %}<p class="alert">{{ error }}</p>{% endif %}
<form method="post" action="/setup" class="stack">
<label>Username <input name="username" autocomplete="username" required></label>
<label>Password <input name="password" type="password" autocomplete="new-password" required></label>
<label>Confirm <input name="confirm_password" type="password" autocomplete="new-password" required></label>
<button type="submit">Create app login</button>
</form>
</main>
</body>
</html>

32
templates/trace.html Normal file
View File

@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block content %}
<section class="page-head">
<div>
<p class="eyebrow">Trace #{{ trace.id }}</p>
<h1>{{ trace.action }} / {{ trace.status }}</h1>
</div>
<a class="button secondary" href="/traces">Back to traces</a>
</section>
<section class="metrics">
<article><span>Date</span><strong>{{ trace.scheduled_date }}</strong></article>
<article><span>Source</span><strong>{{ trace.source_name or "-" }}</strong></article>
<article><span>Clone</span><strong>{{ trace.clone_workout_name or "-" }}</strong></article>
<article><span>Hash</span><strong class="hash">{{ trace.source_hash or "-" }}</strong></article>
</section>
<section class="panel">
<header><h2>Message</h2></header>
<p>{{ trace.message }}</p>
</section>
{% for key, value in json.items() %}
<section class="panel">
<header>
<h2>{{ key.replace("_json", "").title() }} JSON</h2>
<a class="button secondary" href="/traces/{{ trace.id }}/{{ key.replace('_json', '') }}.json">Download</a>
</header>
<pre>{{ value }}</pre>
</section>
{% endfor %}
{% endblock %}

38
templates/traces.html Normal file
View File

@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block content %}
<section class="page-head">
<div>
<p class="eyebrow">Workout tracing</p>
<h1>Traces</h1>
</div>
</section>
<section class="panel">
<table>
<thead>
<tr>
<th>Time</th>
<th>Date</th>
<th>Action</th>
<th>Status</th>
<th>Source</th>
<th>Clone</th>
</tr>
</thead>
<tbody>
{% for trace in traces %}
<tr>
<td><a href="/traces/{{ trace.id }}">{{ trace.created_at }}</a></td>
<td>{{ trace.scheduled_date }}</td>
<td>{{ trace.action }}</td>
<td><span class="badge">{{ trace.status }}</span></td>
<td>{{ trace.source_name or "-" }}</td>
<td>{{ trace.clone_workout_name or "-" }}</td>
</tr>
{% else %}
<tr><td colspan="6" class="empty">No workout traces yet.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View File

@ -0,0 +1,56 @@
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
def test_analyze_dump_finds_and_clones_fixture(tmp_path: Path) -> None:
fixture = tmp_path / "coach.json"
fixture.write_text(
json.dumps(
{
"date": "2026-06-16",
"workout": {
"workoutName": "Coach Ride",
"sportType": {"sportTypeKey": "cycling"},
"estimatedDurationInSecs": 60,
"workoutSegments": [
{
"sportType": {"sportTypeKey": "cycling"},
"workoutSteps": [
{
"type": "ExecutableStepDTO",
"stepType": {"stepTypeKey": "warmup"},
"endCondition": {"conditionTypeKey": "time"},
"endConditionValue": 60,
"targetType": {"workoutTargetTypeKey": "no.target"},
}
],
}
],
},
}
),
encoding="utf-8",
)
result = subprocess.run(
[
sys.executable,
"scripts/analyze_dump.py",
str(fixture),
"--date",
"2026-06-16",
"--clone-dry-run",
],
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0
assert "Local clone payload passed validation." in result.stdout
assert "GCClone 2026-06-16 Coach Ride" in result.stdout

57
tests/test_app.py Normal file
View File

@ -0,0 +1,57 @@
from __future__ import annotations
import importlib
from fastapi.testclient import TestClient
def test_first_launch_setup_login_and_health(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("DATA_DIR", str(tmp_path))
monkeypatch.setenv("APP_PASSWORD", "change-me")
monkeypatch.setenv("APP_SECRET_KEY", "test-secret")
module = importlib.import_module("garmin_coach_clone.app")
app = module.create_app()
with TestClient(app) as client:
health = client.get("/healthz")
assert health.status_code == 200
assert health.json()["app_configured"] is False
response = client.post(
"/setup",
data={
"username": "owner",
"password": "very-secret",
"confirm_password": "very-secret",
},
follow_redirects=False,
)
assert response.status_code == 303
assert "gcc_session" in response.headers["set-cookie"]
dashboard = client.get("/")
assert dashboard.status_code == 200
assert "Dashboard" in dashboard.text
search = client.get("/search")
assert search.status_code == 200
assert "Garmin credentials are not configured" in search.text
def test_env_bootstrap_login(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("DATA_DIR", str(tmp_path))
monkeypatch.setenv("APP_USERNAME", "admin")
monkeypatch.setenv("APP_PASSWORD", "boot-secret")
monkeypatch.setenv("APP_SECRET_KEY", "test-secret")
module = importlib.import_module("garmin_coach_clone.app")
app = module.create_app()
with TestClient(app) as client:
health = client.get("/healthz")
assert health.json()["app_configured"] is True
response = client.post(
"/login",
data={"username": "admin", "password": "boot-secret"},
follow_redirects=False,
)
assert response.status_code == 303

51
tests/test_coach.py Normal file
View File

@ -0,0 +1,51 @@
from __future__ import annotations
from datetime import date
from garmin_coach_clone.coach import (
best_workout_for_date,
find_workout_like_objects,
task_workout_for_date,
)
def test_best_workout_for_date_requires_date_match() -> None:
plan = {
"phases": [
{
"scheduledDate": "2026-06-17",
"workout": {"workoutSegments": [{"workoutSteps": []}]},
}
]
}
assert best_workout_for_date(plan, date(2026, 6, 16)) is None
def test_best_workout_for_date_returns_nested_matched_workout() -> None:
workout = {"workoutSegments": [{"workoutSteps": []}]}
plan = {"items": [{"date": "2026-06-16", "workout": workout}]}
assert best_workout_for_date(plan, date(2026, 6, 16)) == workout
def test_find_workout_like_objects_finds_segments_and_steps() -> None:
data = {
"a": {"workoutSegments": []},
"b": [{"workoutSteps": []}],
}
assert len(find_workout_like_objects(data)) == 2
def test_task_workout_for_date_returns_adaptive_task_workout() -> None:
workout = {"workoutName": "Sprint", "workoutUuid": "abc"}
plan = {
"taskList": [
{"calendarDate": "2026-06-15", "taskWorkout": {"workoutName": "Base"}},
{"calendarDate": "2026-06-16", "taskWorkout": workout},
]
}
assert task_workout_for_date(plan, date(2026, 6, 16)) == workout
assert task_workout_for_date(plan, date(2026, 6, 17)) is None

45
tests/test_redact.py Normal file
View File

@ -0,0 +1,45 @@
from __future__ import annotations
from garmin_coach_clone.redact import redact
def test_redact_preserves_workout_schema_ids() -> None:
data = {
"stepType": {"stepTypeId": 1, "stepTypeKey": "warmup"},
"targetType": {"workoutTargetTypeId": 1, "workoutTargetTypeKey": "no.target"},
"endCondition": {"conditionTypeId": 2, "conditionTypeKey": "time"},
}
assert redact(data) == data
def test_redact_private_account_and_device_fields() -> None:
data = {
"email": "rider@example.com",
"displayName": "Rider",
"unitId": 123456,
"ownerId": 999,
"deviceId": 888,
}
redacted = redact(data)
assert redacted["email"] == "<redacted>"
assert redacted["displayName"] == "<redacted>"
assert redacted["unitId"] == "<redacted>"
assert redacted["ownerId"] == "<redacted>"
assert redacted["deviceId"] == "<redacted>"
def test_redact_sensitive_values_inside_strings() -> None:
data = {
"message": "Contact rider@example.com with Authorization: Bearer abc.def.ghi",
}
redacted = redact(data)
assert "rider@example.com" not in redacted["message"]
assert "abc.def.ghi" not in redacted["message"]
assert "<redacted-email>" in redacted["message"]
assert "Bearer <redacted-token>" in redacted["message"]

33
tests/test_repository.py Normal file
View File

@ -0,0 +1,33 @@
from __future__ import annotations
import pytest
from garmin_coach_clone.config import DEFAULT_FIXED_TIMES, load_settings
from garmin_coach_clone.db import Database
from garmin_coach_clone.repository import Repository, ScheduleConfig, validate_schedule_config
def test_schedule_restore_defaults(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("DATA_DIR", str(tmp_path))
settings = load_settings()
db = Database(tmp_path / "app.db")
db.initialize()
repo = Repository(db, settings)
restored = repo.restore_default_schedule()
assert restored.fixed_times == DEFAULT_FIXED_TIMES
assert repo.schedule_config().active_window == "05:00-22:00"
def test_schedule_validation_rejects_bad_window() -> None:
with pytest.raises(ValueError, match="active window"):
validate_schedule_config(
ScheduleConfig(
enabled=True,
interval_minutes=30,
active_window="22:00-05:00",
fixed_times=["05:15"],
days_ahead=1,
)
)

147
tests/test_sync_service.py Normal file
View File

@ -0,0 +1,147 @@
from __future__ import annotations
from datetime import date, datetime
from garmin_coach_clone.config import load_settings
from garmin_coach_clone.db import Database
from garmin_coach_clone.repository import Repository, ScheduleConfig
from garmin_coach_clone.sync_service import SyncService, should_run_now
from garmin_coach_clone.workouts import build_dummy_cycling_workout, workout_source_hash
class FakeClient:
def __init__(self) -> None:
self.uploads: list[dict] = []
self.scheduled: list[tuple[str, str]] = []
self.unscheduled: list[str] = []
self.deleted: list[str] = []
def get_workouts(self, limit: int = 100) -> list[dict]:
return []
def upload_workout(self, payload: dict) -> dict:
self.uploads.append(payload)
return {"workoutId": f"w{len(self.uploads)}"}
def schedule_workout(self, workout_id: str, date_s: str) -> dict:
self.scheduled.append((workout_id, date_s))
return {"scheduledWorkoutId": f"s{len(self.scheduled)}"}
def unschedule_workout(self, scheduled_id: str) -> None:
self.unscheduled.append(scheduled_id)
def delete_workout(self, workout_id: str) -> None:
self.deleted.append(workout_id)
class FakeGarmin:
def __init__(self, source: dict | None) -> None:
self.client = FakeClient()
self.source = source
def authenticated_client(self) -> FakeClient:
return self.client
def active_adaptive_plan(self, client: FakeClient) -> dict:
_ = client
return {"trainingPlanId": 1}
def adaptive_plan_detail(self, client: FakeClient, plan: dict) -> dict:
_ = client, plan
return {}
def coach_workout_for_date(
self, client: FakeClient, plan_detail: dict, target_date: date
) -> tuple[dict | None, dict | None]:
_ = client, plan_detail, target_date
if self.source is None:
return {"restDay": True, "workoutUuid": "rest"}, None
return {"restDay": False, "workoutUuid": self.source["workoutUuid"]}, self.source
def test_should_run_now_for_interval_and_fixed_times() -> None:
config = ScheduleConfig(
enabled=True,
interval_minutes=30,
active_window="05:00-22:00",
fixed_times=["06:15"],
days_ahead=1,
)
assert should_run_now(datetime(2026, 6, 16, 6, 0), config)
assert should_run_now(datetime(2026, 6, 16, 6, 15), config)
assert not should_run_now(datetime(2026, 6, 16, 4, 30), config)
def test_sync_creates_missing_clone(tmp_path, monkeypatch) -> None:
service, repo, garmin = _service(tmp_path, monkeypatch, _source("Sprint"))
result = service._run_sync("test", [date(2026, 6, 16)], dry_run=False)
assert result["created"] == 1
assert garmin.client.uploads
mapping = repo.get_clone_mapping("2026-06-16")
assert mapping is not None
assert mapping["status"] == "scheduled"
def test_sync_skips_unchanged_clone(tmp_path, monkeypatch) -> None:
source = _source("Sprint")
service, repo, garmin = _service(tmp_path, monkeypatch, source)
repo.upsert_clone_mapping(
{
"scheduled_date": "2026-06-16",
"source_uuid": source["workoutUuid"],
"source_hash": workout_source_hash(source),
"source_name": "Sprint",
"clone_workout_id": "w1",
"clone_workout_name": "GCClone 2026-06-16 Sprint",
"scheduled_workout_id": "s1",
"status": "scheduled",
"message": "current",
}
)
result = service._run_sync("test", [date(2026, 6, 16)], dry_run=False)
assert result["skipped"] == 1
assert garmin.client.uploads == []
def test_rest_day_with_existing_clone_warns_without_delete(tmp_path, monkeypatch) -> None:
service, repo, garmin = _service(tmp_path, monkeypatch, None)
repo.upsert_clone_mapping(
{
"scheduled_date": "2026-06-16",
"source_uuid": "old",
"source_hash": "old",
"source_name": "Old",
"clone_workout_id": "w1",
"clone_workout_name": "GCClone 2026-06-16 Old",
"scheduled_workout_id": "s1",
"status": "scheduled",
"message": "old",
}
)
result = service._run_sync("test", [date(2026, 6, 16)], dry_run=False)
assert result["warnings"] == 1
assert garmin.client.unscheduled == []
assert garmin.client.deleted == []
def _service(tmp_path, monkeypatch, source: dict | None):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
settings = load_settings()
db = Database(tmp_path / "app.db")
db.initialize()
repo = Repository(db, settings)
garmin = FakeGarmin(source)
return SyncService(settings, repo, garmin), repo, garmin
def _source(name: str) -> dict:
workout = build_dummy_cycling_workout(name)
workout["workoutUuid"] = f"uuid-{name}"
return workout

125
tests/test_workouts.py Normal file
View File

@ -0,0 +1,125 @@
from __future__ import annotations
from datetime import date
from garmin_coach_clone.workouts import (
build_dummy_cycling_workout,
calendar_entry_date,
calendar_entry_id,
calendar_entry_name,
clone_workout_payload,
estimate_duration,
existing_clone_names,
find_generated_calendar_entry,
find_generated_workout,
generated_calendar_entries,
generated_workouts,
validate_workout_payload,
)
def test_dummy_workout_has_expected_duration_and_steps() -> None:
workout = build_dummy_cycling_workout("Dummy")
assert workout["workoutName"] == "Dummy"
assert workout["sportType"]["sportTypeKey"] == "cycling"
assert estimate_duration(workout) == 14 * 60
assert validate_workout_payload(workout) == []
def test_clone_payload_strips_ids_and_sets_prefix() -> None:
source = build_dummy_cycling_workout("Coach Original")
source["workoutId"] = 123
source["ownerId"] = 456
source["workoutSegments"][0]["workoutSteps"][0]["stepId"] = 789
cloned = clone_workout_payload(source, date(2026, 6, 16), "GCClone")
assert cloned["workoutName"] == "GCClone 2026-06-16 Coach Original"
assert "workoutId" not in cloned
assert "ownerId" not in cloned
assert "stepId" not in cloned["workoutSegments"][0]["workoutSteps"][0]
assert validate_workout_payload(cloned) == []
def test_existing_clone_names_filters_by_prefix_and_date() -> None:
names = existing_clone_names(
[
{"workoutName": "GCClone 2026-06-16 Ride"},
{"workoutName": "GCClone 2026-06-17 Ride"},
{"workoutName": "Other 2026-06-16 Ride"},
],
date(2026, 6, 16),
"GCClone",
)
assert names == ["GCClone 2026-06-16 Ride"]
def test_generated_workouts_filters_by_prefix() -> None:
workouts = [
{"workoutId": 1, "workoutName": "GCClone 2026-06-16 Ride"},
{"workoutId": 2, "workoutName": "GCClone Probe Dummy 2026-06-16"},
{"workoutId": 3, "workoutName": "Real Workout"},
]
assert generated_workouts(workouts, "GCClone") == workouts[:2]
assert find_generated_workout(workouts, "2", "GCClone") == workouts[1]
assert find_generated_workout(workouts, "3", "GCClone") is None
def test_generated_calendar_entries_handle_nested_workout_names() -> None:
generated_entry = {
"scheduledWorkoutId": 99,
"scheduledDate": "2026-06-16",
"workout": {"workoutName": "GCClone 2026-06-16 Ride"},
}
data = {
"calendarItems": [
generated_entry,
{
"scheduledWorkoutId": 100,
"scheduledDate": "2026-06-16",
"workout": {"workoutName": "Real Workout"},
},
]
}
entries = generated_calendar_entries(data, "GCClone")
assert entries == [generated_entry]
assert find_generated_calendar_entry(data, "99", "GCClone") == generated_entry
assert find_generated_calendar_entry(data, "100", "GCClone") is None
assert calendar_entry_id(generated_entry) == "99"
assert calendar_entry_date(generated_entry) == "2026-06-16"
assert calendar_entry_name(generated_entry) == "GCClone 2026-06-16 Ride"
def test_validate_workout_payload_rejects_missing_segments() -> None:
errors = validate_workout_payload(
{
"workoutName": "Broken",
"sportType": {"sportTypeKey": "cycling"},
"workoutSegments": [],
}
)
assert "workoutSegments must be a non-empty list" in errors
def test_validate_workout_payload_rejects_non_cycling() -> None:
workout = build_dummy_cycling_workout("Broken")
workout["sportType"] = {"sportTypeKey": "running"}
errors = validate_workout_payload(workout)
assert "sportType must be cycling, got running" in errors
def test_validate_workout_payload_rejects_missing_step_target() -> None:
workout = build_dummy_cycling_workout("Broken")
workout["workoutSegments"][0]["workoutSteps"][0].pop("targetType")
errors = validate_workout_payload(workout)
assert "workoutSegments[1].workoutSteps[1].targetType is missing" in errors

1063
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff