30 Aug 2025
The Architecture of an Indie Apple Watch App
In August 2025 we spent a weekend reorganizing the entire codebase — about 30 Swift files moved out of flat Views/ and Models/ directories into feature folders. Two days of work that's saved us hours of friction every week since.

That's the most important refactor in the whole project. The rest of this article is variations on the same theme — wrong turns, dependencies we regretted, the file that grew to 2,055 lines before we split it.
The starting point
January 2025. We sat down and estimated the project at two to three months. The plan was:
  • Calendar view. How hard can be?
  • Plan generator. ChatGPT can write one from a single prompt, right?
  • Push the workouts into Apple Fitness via whatever API Apple provides. Third-party push to Garmin all the time.

Both assumptions turned out to be wrong, in different ways:
  • Apple Fitness has no API for pushing structured workouts. No equivalent of Garmin Connect’s training API in the Apple ecosystem. To run a structured workout on Apple Watch you have to build your own Watch app that creates and executes the workout via HealthKit’s lower-level primitives. .
  • Garmin Connect has an API, but it’s not free. Their workout push API is behind a paid developer program (~€ 450/year), requires applying and being approved, and uses Monkey C. We saw running apps integrating with Garmin and assumed the path was open. It is — but you pay for it.

ChatGPT-generated plans look reasonable but aren’t structurally consistent — fine for a casual user, wrong for a real training tool (full version: The 308 Fartleks). And SwiftUI calendars are fine when you’re using one for show — the moment you need a real month-grid that scrolls, scales, marks events, and syncs with data updates from a Watch app, you’re writing it from scratch.

Run Plan started as Katya's MVP while she was training for the Amsterdam Marathon (October 2025). I joined a few weeks later. First commit in our shared repo: April 2025. Current version on the App Store: v1.10, May 2026. Thirteen months of evenings and weekends from a 2-person part-time team.

"Two to three months" was off by a factor of about six.

The early architecture was, generously, "make it work." Everything sat in one big file, with UI mixed into logic and CoreData fetches inside view bodies. We had:
  • A PlanUtils file that eventually grew to >2,000 lines — plan generation, distribution, AND configuration.
  • A ContentViewWrapper that owned all app state via dozens of @State properties.
  • JTCalendar (a UIKit OSS library) for the calendar view.
  • A single Watch app target wired directly to CoreData.
It worked. It was fragile. We knew it. We didn't have time to fix it.
The story of Run Plan’s architecture is mostly the story of paying back that early, three or four big refactors at a time.
The major rewrites, in order
August 2025 — feature-based reorganization
The codebase had ~30 Swift files, mostly flat in directories like Views/ and Models/. Over one weekend the whole thing got reorganized:
Features/
├── Plans/
├── Workouts/
├── Activity/
├── Analytics/
├── Onboarding/
└── Settings/
Services/
├── Data/         (CoreDataManager)
├── Health/       (HealthKit, WorkoutSharing)
├── Location/     (LocationKitManager)
└── Connectivity/ (WatchConnectivityManager)
Feature-based, not type-based. Each feature owns its views, models, and view-models. Services are domain-specific singletons.

In retrospect: the most important refactor we did. Every subsequent change benefited from the structure being clear. Adding a new feature = adding a folder. Finding a bug = going to the feature first, then up to a service if needed.

Cost: one weekend. Benefit: compounded across 9 months and counting.
Lesson. If you started fast and got the structure wrong, fix it before you have 50+ files. The cost grows with size.
November 2025 — CoreData schema cleanup
Feature-based, not type-based. Each feature owns its views, models, and view-models. Services are domain-specific singletons.

First version of our schema: a single WorkoutEvent entity that had everything. Planned workout type. Completion state. Actual HK metrics. Plan reference. Scheduling state. Worked until it didn't.

The rewrite split it into:
  • PlanEntity — the plan (race date, distance, level, training days).
  • WorkoutEntity — the planned workout template (type, duration, intervals).
  • WorkoutEventEntity — the calendar event tying a Plan to a Workout on a specific date (completion, isCancelled, originalDate, isSwapped, etc.).
  • WorkoutIntervalEntity — the actual structure of an interval workout (warmup/work/recovery/cooldown).
  • MetadataEntity — key-value scratch space for plan state.
That's roughly the schema today. Three targets (iPhone, Watch, widget) share the same Core Data store via an App Group container. Both apps read and write; CoreData handles concurrency.

Cost: ~1 week of work + a painful lightweight migration on user-installed apps (some users had to recreate their plan because the migration failed for old data shapes). We learned to test migration paths on real user data dumps before shipping schema changes.
January 2026 — PlanUtilsV2 grew to 2,055 lines
The load-calculation infrastructure. By January 2026 the file had:

  • Plan configuration structs (every distance × level combination).
  • Phase progression math.
  • Weekly target calculation (load + duration).
  • Workout selection scoring.
  • Workout distribution across training days.
  • Hard/easy interleaving logic.
  • Surprise week handling.
  • Deload week math.
  • Race week skip-quality logic.

All in one file. 2,055 lines. Every change risked breaking three other things. Test coverage was reasonable. The file was unsearchable.

[CODE SCREENSHOT: Xcode minimap of PlanUtilsV2.swift, looking like a small mountain range. The kind of file you only open when you have an hour.]
March 2026 — AppStateManager + PlanUtils split
A 4-phase refactor over about a week:

Phase 1. Housekeeping. Remove dead code, fix warnings, normalize naming.

Phase 2. Extract AppStateManager from ContentViewWrapper. The wrapper had become an 800-line god object holding plan state, HealthKit sync, event rebuilding, plan lifecycle. We pulled all of that into a dedicated ObservableObject injected via .environmentObject(). The wrapper became a 30-line shell that hosted the actual UI tree.

Phase 3. Split PlanUtilsV2.swift (2,055 lines) into 3 files:
  • PlanConfiguration.swift (~473 lines) — enums, config structs, defaults.
  • PlanGenerator.swift (~1,055 lines) — the plan generation engine.
  • PlanDistribution.swift (~544 lines) — workout type distribution.

Phase 4. Split PlanConfigurationView.swift (1,370 lines) — extract RunningLevel.swift and related view models.

Why this matters: a file you can navigate is a file you can change without fear. After this refactor, almost every subsequent change to plan generation (the audit, the catalog rebalance, the new subtypes) was easier than it would have been on the monolithic file.
March 2026 — JTCalendar → pure SwiftUI calendar
This is the OSS-dependency story.

We started with JTCalendar — an open-source UIKit calendar library — for the main month-view tab. The right call at the start: get a calendar working in 20 minutes, ship.

Over time we hit friction:
  • Scroll position resetting on data updates. Sync from the Watch refreshed the calendar's data → JTCalendar reset to the current month → jumped the user away from wherever they were scrolling.
  • Wrong month on tab switch. Switching tabs and coming back showed last month sometimes, depending on state.
  • Container wrapper layout issues. Embedding the UIKit calendar in SwiftUI required UIViewControllerRepresentable, which fights with safe area insets, dynamic type, and the rest of the SwiftUI layout system.
  • iOS 26 visual mismatch. When iOS 26 introduced Liquid Glass, JTCalendar's UIKit rendering didn't match — our calendar header looked dated next to the rest of the app.
A few weekends of patching followed, each one adding complexity and its own subtle regressions.

By March 2026 we'd had enough. JTCalendar reset to August one too many times, and we opened a fresh file and started typing a replacement. (Claude Code was the buddy on this one — programming a 600-line SwiftUI calendar from scratch goes soo muuch faster with it.)

A pure SwiftUI calendar:
  • LazyVStack of month grids.
  • ScrollViewReader for instant scroll-to-today.
  • Sticky glass weekday header with a fade mask.
  • Workout event markers for completed/cancelled/race states.

Total: ~600 lines of SwiftUI.
Less feature-complete than JTCalendar on day one. But:
  • Native SwiftUI → composed with the rest of the app.
  • Owned by us → every quirk had a fixable cause.
  • Designed around our exact data model.
  • Visually consistent with iOS 26's design language.
Lesson. An OSS dependency is a relationship. Day 1 is free. Day 400 is paid. If the library doesn't match your domain closely, the cost of working around mismatches grows. Sometimes the right move is to write your own — but only after the friction is concrete and recurring, not before.
How many times we've rewritten load calculation
Four times, end to end.

  1. April-July 2025. Inline in the plan generation code. Each workout had a hardcoded training_load number; the engine summed them per week and called it done.
  2. August 2025. Target-based system. Target load per week = base × phase multiplier × level multiplier. Engine picked workouts whose load best matched.
  3. November 2025 – February 2026. Smooth phase transitions, mid-phase recovery, surprise weeks, the weeklyLoadIncreasePercent curve. Most of the math in the engine today.
  4. May 2026 (post-audit). Link to be added.

Each rewrite was driven by a specific failure mode of the previous version. Version 3 exists because v2 produced jarring step-jumps between phases. There should be a story of V4 that actually created a lot of marathon and half marathon plans that had significantly lower number of miles/kilometers that a normal trainer would prescribe..

This is the part nobody tells you about plan engines: getting it right requires a phase of its own. The engine improves by being audited against real-world reference plans, iteratively, over months. Unfortunately, there is no closed-form "correct" answer you implement once.
Two regrets, two wins
Regrets:
  • We probably reorganized too late. The August 2025 feature-based refactor should have happened the moment we had >15 files.
  • Actual plan debugging tooling should have been week 2, not month 13. Tooling that lets you see what your system produces accelerates everything else.

Wins:
  • No backend. Best decision we made. Every "should we add a server for X?" question has had the same answer: no. CoreData + HealthKit + WatchConnectivity covers more than we expected.
  • Pure-function plan engine. Same inputs → same outputs. Made testing easy, debugging fast, and the standalone CLI possible.
What we still want to fix
Honest list of known problems:
  • Pace catalog parity. The HR catalog has ~740 workouts; the pace catalog is a runtime conversion of the HR catalog via PaceZoneConverter. Works. But the convert-at-runtime path is more complex than just having two parallel catalogs.
  • CoreData migration testing. We test new schemas on fresh installs and our personal devices. We don't systematically test the migration path from every prior schema. Need to fix this before the paid tier launches with grandfathering.
  • Watch app UI test coverage. Per the testing post, genuinely hard. Open problem.
  • No real plan adaptation. Plans are generated upfront and don't change based on what you actually did. Adaptive load is on the roadmap. Will require a WeeklyAdjuster that runs Sunday evening, reads HealthKit completion, decides whether next week's load should shift.
The principle that's worked
If I had to summarize what's worked across all of this:
Move slowly. Refactor often. Trust the boring choices.
Specifically:
  • SwiftUI for everything we can. UIKit interop is a maintenance tax.
  • CoreData even when SwiftData looks tempting. We used CoreData, and we didn't use SwiftData. Sounds fun, will use in the future.
  • HealthKit as the system of record for workouts. Apple owns it. We actually trying to make our workouts look in Apple Fitness as close to Apple's own Workouts. The goal is to not prevent users from using their preferred tooling.
  • One language (Swift), one platform (Apple), one team (us). Constraints clarify decisions.
The architecture isn't perfect. There are corners we'd rebuild if we started over. But it's the architecture of an app that works, ships, has real runners using it, and survives both authors holding full-time jobs.
Further reading
The 8-Month Bug — what the cached row generation paid for.
The Kalman Filter Story — the GPS outlier rejection that replaced ~2 months of work.
The 308 Fartleks — what the plan_debug CLI made possible.
iOS 26 Liquid Glass — what made JTCalendar finally untenable.
Apple — App Groups — the mechanism behind the shared CoreData store.
Run Plan is an indie iOS + Apple Watch training planner built by a 2-person team in Amsterdam. No accounts, no ads, no subscription. Your data stays on your device.