# 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 → `MetadataObserver` (an `NSMetadataQuery`) emits a delta batch → `CacheMapper` upserts SwiftData → `@Query` views refresh. The phone is the **sole writer** of iCloud Drive. - **Key types**: - `SyncEngine` (`@Observable @MainActor`, in `Workouts/Sync/`) — the app-specific orchestrator. `connect()` resolves the ubiquity container and reconciles; `save`/`delete` write files; `handle(_:)` applies observer delta batches; `ingestFromWatch(_:)` writes + upserts the cache directly. Exposes `iCloudStatus` and an `onCacheChanged` callback. - The file-side core comes from the **IndieSync** SPM package (`https://git.rzen.dev/rzen/indie-sync.git`): `DocumentFileStore` (actor — all `NSFileCoordinator` I/O, conflict resolution, eviction-safe reads, placeholder-aware enumeration), `TombstoneStore` (soft-delete stubs, resurrection veto, grace-period pruning), `MetadataObserver` (`@MainActor` — wraps the `NSMetadataQuery`, emitting added/modified/removed batches as an `AsyncStream`), plus `ULID`, `DocumentCoder`, `SyncError`, and the `VersionedDocument` protocol. See the `icloud-sync-engine` skill for the architecture invariants. - **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]`), conforming to IndieSync's `VersionedDocument` schema gate (quarantines files from newer app versions). `Tombstone` and `DocumentCoder` come from IndieSync. `WorkoutDocument.relativePath` keeps the app's own local-calendar month bucketing (predates IndieSync's UTC `TimeBucketedLayout`; changing it would strand existing files). - **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`) — a thin shim over IndieSync's `ULID`: `ULID.make()` mints the 26-char Crockford base32 string (chronologically sortable) that documents and entities key on. - **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 (git commit count), `BuildDate`, and `BuildHash` are stamped by the `app-versioning` skill's `update_build_info.sh` (referenced in place from `../indie-skills`, a post-build phase; release tagging is opt-in via `RELEASE_TAGGING=1`). `Scripts/` holds the App Store / TestFlight release pipeline. - Entitlements: iOS uses CloudDocuments + HealthKit; watch uses HealthKit only. **No CloudKit service, no App Group.** - SPM packages: `IndieSync` (file-side iCloud sync core), `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 `currentSchemaVersion` 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 IndieSync's `DocumentFileStore` 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 — follow the `app-changelog` skill (single source of truth for format and workflow).