Refresh CLAUDE.md for 2.0; link changelog from the version in About

- Rewrite the stale CoreData/CloudKit architecture docs to describe the
  2.0 iCloud Drive document architecture (JSON source of truth + SwiftData
  cache, SyncEngine/ICloudFileManager/ICloudFileMonitor, ULID document/
  entity/mapper split, AppServices DI, WatchConnectivity bridge, XcodeGen/
  Swift 6/iOS 26, real directory layout).
- Add an "Authoring the Changelog" section documenting the end-user,
  one-paragraph-per-entry, derive-but-rewrite-from-git-log convention.
- About screen: make the version line open the changelog (IndieAbout
  0.2.0 changelogDocument) and drop the separate "Changelog" link; bump
  the IndieAbout dependency to from: 0.2.0.

Claude-Session: https://claude.ai/code/session_01A9CfUa4E9Zd5swfoNsYPs7
This commit is contained in:
2026-06-20 15:33:09 -04:00
parent 16a15621dd
commit 1a0c484177
3 changed files with 71 additions and 63 deletions
+67 -59
View File
@@ -6,10 +6,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
This is a workout tracking iOS app built with Swift/SwiftUI, featuring both iPhone and Apple Watch companions. The app helps users manage workout splits, track exercise logs, and sync data across devices using CloudKit.
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)
@@ -17,76 +21,80 @@ This is a workout tracking iOS app built with Swift/SwiftUI, featuring both iPho
## Architecture
### Data Layer
- **CoreData**: Core persistence framework with CloudKit sync via `NSPersistentCloudKitContainer`
- **CloudKit Container**: `iCloud.dev.rzen.indie.Workouts`
- **PersistenceController**: Manages CoreData stack initialization, CloudKit configuration, and context access
- **Models**: Located in `Models/` directory as NSManagedObject subclasses
### 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 → `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/<id>.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.
### Core Models
- **Split**: Workout routine templates with exercises, colors, and system images
- **Exercise**: Individual exercises within splits (sets, reps, weight, load type)
- **Workout**: Active workout sessions linked to splits
- **WorkoutLog**: Historical exercise completion records
- **WorkoutStatus**: Enum for tracking workout/exercise completion states
### 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`.
### Model Relationships
```
Split (1) ──cascade──> (many) Exercise
Split (1) ──nullify──> (many) Workout
Workout (1) ──cascade──> (many) WorkoutLog
```
### 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
- **Dual targets**: Main iOS app (`Workouts/`) and Watch companion (`Workouts Watch App/`)
- **Shared components**: Both apps have similar structure with platform-specific implementations
- **TabView navigation**: Main app uses tabs (Workouts, Logs, Reports, Achievements)
### 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(...)`.
### CloudKit Integration
- **Automatic sync**: Configured via `NSPersistentCloudKitContainerOptions`
- **History tracking**: Enabled for CloudKit sync via `NSPersistentHistoryTrackingKey`
- **Remote change notifications**: Enabled for real-time sync updates
- **Cross-device sync**: Data syncs between iPhone and Apple Watch
### 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`.
### UI Patterns
- **SwiftUI-first**: Modern declarative UI throughout
- **Environment injection**: ManagedObjectContext passed via `.environment(\.managedObjectContext)`
- **Navigation**: Uses NavigationStack for hierarchical navigation
- **Form-based editing**: Consistent form patterns for data entry
### 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
- `Models/`: CoreData NSManagedObject subclasses
- `Persistence/`: PersistenceController for CoreData stack management
- `Views/`: UI components organized by feature (Splits, Exercises, Workouts, etc.)
- `Utils/`: Shared utilities (date formatting, colors)
- `*.xcdatamodeld`: CoreData model definition
- `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)
### CoreData Guidelines
- Each model gets its own file in `Models/`
- Use `@NSManaged` for all persistent properties
- Implement `fetchRequest()` class methods for type-safe fetching
- Use `NSSet` for to-many relationships with convenience array accessors
- Implement add/remove helper methods for relationship management
- Use appropriate delete rules: cascade for owned children, nullify for references
### 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.
### UI Guidelines
- Tab-based root navigation with independent navigation stacks
- Consistent form patterns for add/edit operations
- Sheet presentations for modal operations
- Swipe gestures for common actions (edit, complete, navigate)
## Guidelines
### Development Notes
- **Preview support**: `PersistenceController.preview` for SwiftUI previews
- **Color system**: Custom color extensions for consistent theming (`Color.color(from:)`)
- **Date formatting**: Extensions in `Date+Extensions.swift`
### 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.
## Changelog
### Concurrency
- Code is Swift 6 strict-concurrency clean. UI and orchestration types are `@MainActor`; file I/O lives in the `ICloudFileManager` actor.
`CHANGELOG.md` is shown in-app via IndieAbout, so it is written for end users, not developers:
### 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/`).
- Group entries by month; newest month first.
- Write each entry as its own blank-line-separated paragraph — no bullet or dash markers, which the Apple inline-Markdown subset IndieAbout renders doesn't style.
- Derive entries from the git log, but rewrite (don't copy) each into a concise one-liner describing the crux of the change in end-user-understandable terms.
## 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:
- 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 significant to an end user; skip internal / tooling / refactor-only commits.
- When one commit holds several user-facing changes, break them into separate paragraphs.
- 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.
+2 -2
View File
@@ -109,9 +109,9 @@ struct SettingsView: View {
Section {
IndieAbout(configuration: AppInfoConfiguration(
documents: [
.custom(title: "Changelog", filename: "CHANGELOG", extension: "md"),
.license(extension: "md")
]
],
changelogDocument: .changelog()
))
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ settings:
packages:
IndieAbout:
url: https://git.rzen.dev/rzen/indie-about.git
from: "0.1.0"
from: "0.2.0"
Yams:
url: https://github.com/jpsim/Yams
from: "6.0.0"