Build 4 — Plans, the AI coach loop, and .fit export

A Plan tab takes the centre: an active training plan, a Today hero, a week strip, and a library that puts running and strength side by side. A chatbot picker hands the current plan state to Claude, ChatGPT, or Gemini and reads the returned JSON back from iCloud. Planned runs export to any FIT-capable watch (Garmin, Wahoo, COROS) through the iOS share sheet. Settings becomes Profile; History becomes Report.

← All changes

Plan tab

  • PlanTabView takes the first slot in the tab bar. A TodayHeroCard shows today’s planned item up top — a strength session, a running workout, or a rest day — with the appropriate primary action (Start, Send to watch, or nothing). Below it, WeekStripView lays out the six other days of the week as a horizontal strip; tapping a day scrolls the rest of the screen to that day’s detail.
  • Library, running and strength side by side. WorkoutLibraryView and the running plan templates share a single picker surface, with discipline chips (Running coral, Strength / Bodyweight neutral) so the two disciplines never look pasted together.
  • PlanItemLinker. Finished HKWorkouts — including the ones recorded on a Garmin watch and synced via Apple Health — back-link to the closest uncompleted PlanItem when the match score on duration / distance / time-of-day is ≥ 70 %. The Today card flips to “completed” without the user filing anything by hand.

A plan is the memory the rest of the app reads from. Without one, every session was a one-off — useful as a log, useless as a build. The Plan tab puts a six-day horizon in front of the user every time they open the app.

AI coach loop

  • CoachExporter. Builds a prompt containing the user’s goal, the current plan state (active plan, recent sessions, pain trend if rehab is on), and the workout schema, in a single pasteable block.
  • Chatbot picker sheet. Three buttons — Claude, ChatGPT, Gemini — each backed by a universal link that iOS routes into the chatbot’s iOS app if installed, falling back to the web. Tapping a button copies the prompt to the clipboard and opens the chat; the user pastes, sends, and the chatbot interviews them before emitting JSON.
  • PlanImporter. The bot saves the plan JSON to iCloud Drive → Aski → plans. NSMetadataQuery watches the folder and pulls the new plan in without an explicit sync trigger — the same mechanism that has been watching workouts/ since Foundation.

Track, don’t coach still holds. The coach lives in your chatbot, where it belongs; Aski stores the result and runs the sessions. The app is the schedule and the receipt. Nothing on Aski’s side ever calls a chatbot API — the handoff is a clipboard and a universal link, end of integration.

Bundled starter plans

  • Lifting. Four 12-week starters drawn from the r/Fitness wiki: Basic Beginner Routine, GZCLP, 5/3/1 for Beginners, and the Bodyweight Fitness Recommended Routine. Each plan ships with its attribution metadata intact (coachAttribution), so the source is visible in the plan detail view.
  • Running. The existing Pfitzinger and Hal Higdon marathon plans stay where they were, now alongside the lifting set in the same starter picker.
  • One-screen onboarding. OnboardingFirstPlanView collapses the three-stage flow into a single screen with a coral Write one with my AI coach CTA and a neutral Use a bundled plan picker. Everything else (Strava, notifications, Watch setup) defers to Profile until the user actually needs it — invariant #5 intact.

.fit export to FIT-capable watches

  • FitWorkoutEncoder (~500 lines, no deps). Encodes a running PlanItem to binary FIT with the message types Garmin’s workout importer expects: file_id, workout, and N × workout_step, with workout_step.duration_type covering distance, time, lap button, and repeat-from for nested repeat blocks. HR-zone and pace-zone targets pass through. CRC-16 over the full file.
  • FitWorkoutFileWriter writes the byte buffer to a temporary .fit file with a slug-derived filename.
  • Send to watch. A SendToWatchSheet is reachable from RunningWorkoutDetailView and the Plan-tab Today hero. It explains the two-step Garmin Connect mobile import flow and vends the .fit through the iOS share sheet — UIActivityViewController, the standard target picker. Aski has no awareness of where the file lands after that: Garmin Connect, AirDrop to a Wahoo, email to a COROS user, all of those work.

One-way, off by default, and explicitly not the same thing as a Garmin Connect API integration. The Garmin Developer Program application paperwork is filed (docs/garmin-application.md); until that approval lands, a .fit through the share sheet is the deterministic, user-controlled fallback. Real-device validation across watches is ongoing — silent encoding mismatches are exactly the kind of thing that can survive a unit test, so this surface gets a “Phase-2 in progress” tag in the privacy policy and the support page.

IA reshape

  • Settings → Profile. ProfileTabView replaces SettingsView. Six sections in fixed order: Plans (active card + Past plans), Coach loop (iCloud sync state, Open plans folder in Files shortcut), Connections (Strava live, Garmin Phase-2 stub, Apple Health), Training (celebrate PRs, 24h check, keep-screen-awake, mirror form videos to Photos), Watch (rest haptic, tempo haptic, also-tick-on-phone, Watch link log), About (language, how-it-works, version). The full DiagnosticsView is hidden behind 5 taps on the version row.
  • History → Report. ReportTabView replaces the History tab. A PlanProgressCard sits on top — completed days, upcoming days, pain trend if rehab is on — with a Send to coach action that drops a session summary into the same CoachExporter flow.
  • PainReactivityCard. Consolidates PainGateSheet and MorningCheckView into a single component that’s surfaced inline on the session summary and from the 24h-check notification. Same triggers, fewer code paths.
  • No new tab bar visibility. The runner still hides the tab bar during a session, owns its HKWorkoutSession, and never lets the watch end the workout — invariants #3 and #6 intact.

Strings & under-the-hood

  • Phase-1 string extraction sweep into Localizable.xcstringstab.plan, tab.report, tab.profile, onboarding.aiCoach, onboarding.starterPicker.*, onboarding.handoff.*, profile.section.*, plus the FIT export and chatbot-handoff copy.
  • library.title key collision fix between WorkoutLibraryView and LibraryListView — renamed to library.freeWorkouts.title with EN + NL translations.
  • Garmin Developer Program application pack: docs/garmin-application.md. The privacy policy already lists Garmin Connect as Phase 2 (Activity API for runs in, Training API for runs out, tokens in Keychain, revokable). Nothing in Connections → Garmin is wired up yet — the row is a stub until approval lands.

Tests

  • FitWorkoutEncoderTests covers the message-encoding round-trip, CRC-16, repeat-block nesting, and HR / pace target field widths (357 lines).
  • PlanImporterTests covers the iCloud → SwiftData hand-off, including the plans/ folder watch and the conflict path when a plan with the same id already exists.
  • OnboardingFirstPlanView tests cover the chatbot-picker copy-then-open flow and the bundled-plan attribution propagation.