The exercise picker now lists the bundled exercise library — the motion-rig exercises exported from Exercise Library/ — instead of the two *.exercises.yaml starter catalogs. ExerciseListLoader and the Yams dependency are removed; ExerciseMotionLibrary gains exerciseNames, the bundle enumeration the picker reads.
93 lines
10 KiB
Markdown
93 lines
10 KiB
Markdown
# 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 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/<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)**: 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/<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`.
|
|
- 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) 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`
|
|
- 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**: `SyncEngine.autoSeedIfEmpty()` writes the verbatim bundle bytes after connect, only into a verifiably empty container (no data files, no stubs) re-checked after a settle delay — wrong guesses are harmless because identical bytes make same-path conflicts empty and stubs reap resurrected seeds. The on-demand path (`SplitSeeder.seedDefaults`, "Add Starter Splits") restores deleted seeds (lifting the veto stub) or writes missing ones, skipping live names.
|
|
- **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/*.motion.json` rigs, which double as the exercise picker's list (`ExerciseMotionLibrary.exerciseNames`).
|
|
|
|
## 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).
|