1a0c484177
- 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
101 lines
9.3 KiB
Markdown
101 lines
9.3 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 → `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.
|
||
|
||
### 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:
|
||
|
||
- 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, split them into separate paragraphs.
|
||
- When several commits address the same user-facing change, collapse them into one paragraph.
|