# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview A workout tracking app for iPhone and Apple Watch, built with Swift 6 and SwiftUI (iOS 26 / watchOS 26). It helps users manage workout splits, track exercise logs, and sync across devices. As of **2.0**, persistence is an **iCloud Drive document architecture**: JSON files in iCloud Drive are the sole source of truth, and a rebuildable SwiftData store is a read-through cache. Core Data, `NSPersistentCloudKitContainer`, CloudKit, and the App Group were all removed in 2.0. ## Development Commands ### Project Generation - The Xcode project is generated by **XcodeGen** from `project.yml`. After editing `project.yml` (targets, sources, packages, settings), regenerate with `xcodegen generate`. - Do not hand-edit `Workouts.xcodeproj` — it is generated and your changes will be overwritten. ### Building and Running - **Build the project**: Open `Workouts.xcodeproj` in Xcode and build (Cmd+B) - **Run on iOS Simulator**: Select the Workouts scheme and run (Cmd+R) - **Run on Apple Watch Simulator**: Select the "Workouts Watch App" scheme and run ## Architecture ### Persistence & Sync (iCloud Drive documents) - **Source of truth**: JSON documents in the iCloud Drive container `iCloud.dev.rzen.indie.Workouts`. One file per aggregate: `Splits/.json` and `Workouts/YYYY/MM/.json` (month-bucketed; ULIDs sort chronologically). - **SwiftData cache**: `WorkoutsModelContainer` (`Shared/Persistence/`) builds a local SwiftData store as a *rebuildable read-through cache*, created with `cloudKitDatabase: .none`. It is wiped on a schema-version bump or an iCloud account change. Views read it via `@Query`. - **One-way data flow (iPhone)**: view → `SyncEngine.save(...)` writes a file → `ICloudFileMonitor` (an `NSMetadataQuery`) emits a delta → `CacheMapper` upserts SwiftData → `@Query` views refresh. The phone is the **sole writer** of iCloud Drive. - **Key types** (under `Workouts/Sync/`): - `SyncEngine` (`@Observable @MainActor`) — orchestrator. `connect()` resolves the ubiquity container and reconciles; `save`/`delete` write files; `handle(event:)` applies observer deltas; `ingestFromWatch(_:)` writes + upserts the cache directly. Exposes `iCloudStatus` and an `onCacheChanged` callback. - `ICloudFileManager` (`actor`) — all `NSFileCoordinator` file I/O, off the main thread. - `ICloudFileMonitor` (`@MainActor`) — wraps the `NSMetadataQuery`, emitting added/modified/removed deltas as an `AsyncStream`. - **Soft deletes**: a delete writes a `Tombstone` to `Stubs/.json` then removes the live file (so offline devices still learn of the delete); stubs are pruned after a 30-day grace period. - **iCloud is required**: `RootGateView` (`Workouts/Views/`) gates the app on `SyncEngine.iCloudStatus` — there is no local-only mode. ### Data Layer (three shapes + a stateless mapper) All in `Shared/Model/`: - **Codable documents** (`Documents.swift`) — the on-disk / wire format: `SplitDocument` (embeds `[ExerciseDocument]`) and `WorkoutDocument` (embeds `[WorkoutLogDocument]`), plus `Tombstone`, a `VersionedDocument` schema gate (quarantines files from newer app versions), and a shared `DocumentCoder`. - **SwiftData `@Model` cache entities** (`Entities.swift`) — `Split`, `Exercise`, `Workout`, `WorkoutLog`; each keyed by a stable `id: String` ULID (`@Attribute(.unique)`), with cascade relationships and a `jsonRelativePath` back to its file. - **Stateless mappers** (`Mappers.swift`) — `init(from: entity)` for cache → document; `CacheMapper.upsert…` for document → cache (used *only* by the observer, reconcile, and the watch bridge). - **Identifiers** (`ULID.swift`) — 26-char Crockford base32, chronologically sortable. - **Enums** (`Enums.swift`) — `WorkoutStatus`, `LoadType`. ### Core Aggregates - **Split** → embeds many **Exercise** (a single document) - **Workout** → embeds many **WorkoutLog** (a single document); references its split only by denormalized `splitID` / `splitName` — there is no live relationship, workouts are independent documents. ### App Structure & DI - **iOS**: `WorkoutsApp` (`@main`) owns `AppServices` (`@Observable @MainActor`: `container`, `syncEngine`, `watchBridge`, `workoutLauncher`), injects them via `.environment(...)` / `.modelContainer(...)`, then runs `await services.bootstrap()` (connect + activate). Root is `RootGateView` → `ContentView` → `WorkoutLogsView` (a single screen — there is no TabView). - **watchOS**: `WorkoutsWatchApp` (`@main`) owns `WatchAppServices` (`container` + `bridge`; no iCloud, no SyncEngine) and a `WatchAppDelegate`. Root is `ContentView` → `ActiveWorkoutGateView`. - Views read services with `@Environment(SyncEngine.self)` (etc.), read data with `@Query`, and write with `await sync.save(...)` / `delete(...)`. ### Watch Sync (WatchConnectivity bridge) - iPhone is the sole writer of iCloud Drive; the watch is a thin remote that round-trips domain documents through the phone, keyed by stable ULIDs. - **Wire format** `Shared/Connectivity/WCPayload.swift`. Phone→Watch: full state (splits + recent workouts) plus settings (rest seconds, done-countdown seconds) via `updateApplicationContext` (latest-wins). Watch→Phone: `workoutUpdate` (one `WorkoutDocument`) and `requestSync`. - **Phone** `Workouts/Connectivity/PhoneConnectivityBridge.swift` — pushes on `onCacheChanged`; inbound updates go to `SyncEngine.ingestFromWatch`. - **Watch** `Workouts Watch App/Connectivity/WatchConnectivityBridge.swift` — a local SwiftData cache fed only by phone pushes; applies updates optimistically, then forwards. - **Watch HealthKit session** (runtime only, *not* sync): `WorkoutSessionManager` holds an `HKWorkoutSession` so the watch stays foregrounded during a workout; the phone launches it via `Workouts/HealthKit/WorkoutLauncher.swift`. ### Platforms, Tooling & Entitlements - iOS 26 / watchOS 26; Swift 6 with `SWIFT_STRICT_CONCURRENCY: complete` on both targets. - Build number is set from the git commit count by `Scripts/update_build_number.sh` (a post-build phase); `Scripts/` also holds the App Store / TestFlight release pipeline. - Entitlements: iOS uses CloudDocuments + HealthKit; watch uses HealthKit only. **No CloudKit service, no App Group.** - SPM packages: `IndieAbout` (in-app About / changelog / license UI) and `Yams` (YAML parsing). ### Key Directories - `Shared/` (compiled into both targets): `Model/`, `Persistence/`, `Connectivity/`, `Utils/`, `Screenshots/` - `Workouts/` (iOS): `Sync/` (the persistence/sync layer), `Connectivity/`, `HealthKit/`, `Seed/`, `Views/` (`Common/`, `Exercises/`, `Settings/`, `Splits/`, `WorkoutLogs/`), `Resources/` (Info.plist, entitlements, `*.exercises.yaml` catalogs) - `Workouts Watch App/` (watchOS): `Connectivity/`, `Views/`, plus `WorkoutSessionManager`, `WatchAppDelegate`, `WatchAppServices` - Root: `project.yml`, `Scripts/`, and `CHANGELOG.md` / `README.md` / `LICENSE.md` (bundled into the iOS app for IndieAbout) ### Starter Data (on-demand) Seeded on demand, never automatically — an empty cache at launch is indistinguishable from an iCloud library that hasn't downloaded yet: - **Starter splits**: `Workouts/Seed/SplitSeeder.swift` — a curated machine-based routine (Upper Body, Core, Lower Body) at 4×10 with default weights; idempotent by name. - **Exercise catalogs**: `Workouts/Resources/*.exercises.yaml` (Planet Fitness + bodyweight), parsed with Yams, used by the exercise picker as a reference catalog. ## Guidelines ### Data Model Changes - A new persisted field must be added in three places: the Codable `…Document` (on-disk shape), the `@Model` cache entity, and *both directions* of the mapper in `Mappers.swift`. - Bump a document's `currentSchema` when its on-disk shape changes incompatibly. - All writes go through `SyncEngine` (`save` / `delete`) — never mutate the SwiftData cache directly, since it is rebuilt from files. - Mint IDs with `ULID.make()`; they are stable across cache rebuilds. ### Concurrency - Code is Swift 6 strict-concurrency clean. UI and orchestration types are `@MainActor`; file I/O lives in the `ICloudFileManager` actor. ### UI - SwiftUI-first: `NavigationStack`, `@Query`-backed lists, form-based add/edit, sheet presentations, and swipe actions. - Theming via `Color+Extensions.swift`; date formatting via `Date+Extensions.swift` (both in `Shared/Utils/`). ## Authoring the Changelog `CHANGELOG.md` is bundled and shown in-app via IndieAbout, so write it for **end users**, not developers. When you make user-facing changes, add entries following these rules: - No preamble: the file starts directly with the first month heading — no title or intro line (e.g. **Changelog** / "All notable changes…"). - Group entries by month, newest month first; a version milestone (e.g. **2.0**) may prefix the entry it applies to. - Write each entry as its own blank-line-separated paragraph — **no bullet or dash markers**, because the Apple inline-Markdown subset IndieAbout renders doesn't style them (see the `indie-about` skill for the supported subset). - Derive entries from the git log, but rewrite (don't copy) each into a concise, end-user-understandable description of the crux of the change. - Include only changes with substantive meaning for an end user — app behavior, new features, and the like. Skip anything else (internal / tooling / refactor / docs-only commits). - When one commit holds several user-facing changes, split them into separate paragraphs. - When several commits address the same user-facing change, collapse them into one paragraph.