# CLAUDE.md

<!-- rgb(86, 20, 150); -->

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, Apple Watch, and Mac, built with Swift 6 and SwiftUI (iOS 26 / watchOS 26 / macOS 26). The Mac app is a library-management companion (routines, schedules, exercise browser — no workout running). 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/<ULID>.json` and `Workouts/YYYY/MM/<ULID>.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 & Mac)**: view → `SyncEngine.save(...)` writes a file → `MetadataObserver` (an `NSMetadataQuery`) emits a delta batch → `CacheMapper` upserts SwiftData → `@Query` views refresh. The phone and the Mac both write iCloud Drive; the Mac writes **only routine and schedule documents** (whole-document last-writer-wins — it must never write workout documents, whose per-log merge exists only on the watch ingest path).
- **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/<id>.json` then removes the live file (so offline devices still learn of the delete); stubs are pruned after a 30-day grace period — except starter-seed stubs, which are exempt (`prune(exempting:)`) and veto seed resurrection forever.
- **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`.
- **macOS**: `WorkoutsMacApp` (`@main`, in `Workouts Mac/`) owns `MacAppServices` (`container` + `syncEngine` only — no watch bridge, HealthKit, Live Activity, or backup). Root is `MacRootGateView` → `MacContentView` (a `NavigationSplitView`: routines sidebar, schedules, exercise browser). Reconciles on window activation (throttled) because iCloud can sync while the app isn't running; flushes pending writes on deactivation.
- Views read services with `@Environment(SyncEngine.self)` (etc.), read data with `@Query`, and write with `await sync.save(...)` / `delete(...)`.

### Watch Sync (WatchConnectivity bridge)
- The watch never touches iCloud Drive; it 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 / macOS 26; Swift 6 with `SWIFT_STRICT_CONCURRENCY: complete` on all targets.
- Build number, `BuildDate`, and `BuildHash` are stamped by the vendored `Scripts/update_build_info.sh` (a post-build phase; release tagging is opt-in via `RELEASE_TAGGING=1`). Build numbers are **platform-partitioned** from the git commit count — iOS/watchOS get even (2×commits), macOS gets odd (2×commits+1) — because both platforms share one ASC build-number space and the embedded watch app must match the iOS app's `CFBundleVersion`. `Scripts/` also holds the App Store / TestFlight release pipeline.
- Entitlements: iOS uses CloudDocuments + HealthKit; watch uses HealthKit only; macOS uses app sandbox + CloudDocuments (same bundle ID as iOS — universal purchase). **No CloudKit service, no App Group.**
- SPM packages: `IndieSync` (file-side iCloud sync core) and `IndieAbout` (in-app About / changelog / license UI).

### 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, `StarterSplits/`, `ExerciseMotions/`)
- `Workouts Watch App/` (watchOS): `Connectivity/`, `Views/`, plus `WorkoutSessionManager`, `WatchAppDelegate`, `WatchAppServices`
- `Workouts Mac/` (macOS): `WorkoutsMacApp`, `MacAppServices`, `Views/` (`Routines/`, `Schedules/`, `Exercises/`), `Resources/`; also compiles `Workouts/Sync/`, `Workouts/Seed/`, `Workouts/ExerciseFigure/`, and a few portable files from `Workouts/Views/` (see `project.yml`)
- Root: `project.yml`, `Scripts/`, and `CHANGELOG.md` / `README.md` / `LICENSE.md` (bundled into the iOS app for IndieAbout)

### Starter Data (deterministic seeds)
- **Starter splits**: shipped as byte-canonical `SplitDocument` JSON in `Workouts/Resources/StarterSplits/*.split.json` with **fixed ULIDs** (shared `01DXF6DT00` prefix, frozen 2020 timestamp) and fixed content, regenerated only by `Scripts/generate_starter_splits.swift`. `SeedLibrary` (`Workouts/Seed/`) loads the catalog; **seeds are immutable** — `SyncEngine.save(split:)` transparently clones an edited seed to a fresh ULID and soft-deletes the seed, whose stub is exempt from pruning and vetoes resurrection forever (open views follow the identity swap via `sync.currentSplitID(for:)`).
- **Auto-seed & reconcile**: after connect + reconcile, `SyncEngine.seedOrReconcile()` (deferred, settle-delayed) branches on container state so the two seeders can never both fire. An empty container → `autoSeedIfEmpty()` writes the verbatim bundle bytes (re-checked after the settle delay). A non-empty container → `reconcileSeeds()` diffs each bundled seed against its fixed-ULID file via the pure `SeedReconcilePlanner` (`Workouts/Sync/SeedReconcile.swift`): a stale revision is overwritten with current bundle bytes (a **semantic** compare with `schemaVersion` normalized — never a byte compare, since older files carry now-removed keys), a newly-shipped seed with no file and no stub is written (unless a live same-name split already exists, or a newer app version wrote the file), and a deleted seed (stub present) is left vetoed. Wrong guesses are harmless — identical bytes make same-path conflicts empty, stubs reap resurrected seeds, and the veto + name guards are re-checked right before each write. This is safe because a fixed-ULID file can never hold user content (every edit forks to a fresh ULID).
- **Restore Starter Splits** (Settings) is the one deliberate veto lift: `SyncEngine.restoreSeeds()` removes a deleted seed's stub and rewrites the **current bundle bytes** (never the stub's old contents), still skipping seeds whose live file already exists (reconcile handles upgrades) or whose name collides with a live split; it returns the count for a brief in-Settings confirmation.
- **Exercise library**: authored in `Exercise Library/` at the repo root (per-exercise `info.md`, SVG visuals, motion rigs, Python render pipeline); the app bundles the exported `Workouts/Resources/ExerciseMotions/*` resources — `*.motion.json` rigs, which double as the exercise picker's list (`ExerciseMotionLibrary.exerciseNames`), and `*.info.md` reference pages, parsed by `ExerciseInfo` and rendered in the Settings → Library → Exercises detail screen. Re-export both with `python3 render.py --export`.

## 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).
