Populate the previously-empty AccentColor asset (iOS + watch) with the logo purple — a deep shade in light mode, brightened for dark mode and the watch's black background. The exercise Done check now uses that accent color and the in-progress indicator reads as a neutral gray, on both iPhone and Apple Watch.
9.6 KiB
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 editingproject.yml(targets, sources, packages, settings), regenerate withxcodegen generate. - Do not hand-edit
Workouts.xcodeproj— it is generated and your changes will be overwritten.
Building and Running
- Build the project: Open
Workouts.xcodeprojin 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>.jsonandWorkouts/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 withcloudKitDatabase: .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(anNSMetadataQuery) emits a delta →CacheMapperupserts SwiftData →@Queryviews 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/deletewrite files;handle(event:)applies observer deltas;ingestFromWatch(_:)writes + upserts the cache directly. ExposesiCloudStatusand anonCacheChangedcallback.ICloudFileManager(actor) — allNSFileCoordinatorfile I/O, off the main thread.ICloudFileMonitor(@MainActor) — wraps theNSMetadataQuery, emitting added/modified/removed deltas as anAsyncStream.
- Soft deletes: a delete writes a
TombstonetoStubs/<id>.jsonthen 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 onSyncEngine.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]) andWorkoutDocument(embeds[WorkoutLogDocument]), plusTombstone, aVersionedDocumentschema gate (quarantines files from newer app versions), and a sharedDocumentCoder. - SwiftData
@Modelcache entities (Entities.swift) —Split,Exercise,Workout,WorkoutLog; each keyed by a stableid: StringULID (@Attribute(.unique)), with cascade relationships and ajsonRelativePathback 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) ownsAppServices(@Observable @MainActor:container,syncEngine,watchBridge,workoutLauncher), injects them via.environment(...)/.modelContainer(...), then runsawait services.bootstrap()(connect + activate). Root isRootGateView→ContentView→WorkoutLogsView(a single screen — there is no TabView). - watchOS:
WorkoutsWatchApp(@main) ownsWatchAppServices(container+bridge; no iCloud, no SyncEngine) and aWatchAppDelegate. Root isContentView→ActiveWorkoutGateView. - Views read services with
@Environment(SyncEngine.self)(etc.), read data with@Query, and write withawait 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) viaupdateApplicationContext(latest-wins). Watch→Phone:workoutUpdate(oneWorkoutDocument) andrequestSync. - Phone
Workouts/Connectivity/PhoneConnectivityBridge.swift— pushes ononCacheChanged; inbound updates go toSyncEngine.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):
WorkoutSessionManagerholds anHKWorkoutSessionso the watch stays foregrounded during a workout; the phone launches it viaWorkouts/HealthKit/WorkoutLauncher.swift.
Platforms, Tooling & Entitlements
- iOS 26 / watchOS 26; Swift 6 with
SWIFT_STRICT_CONCURRENCY: completeon 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) andYams(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.yamlcatalogs)Workouts Watch App/(watchOS):Connectivity/,Views/, plusWorkoutSessionManager,WatchAppDelegate,WatchAppServices- Root:
project.yml,Scripts/, andCHANGELOG.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@Modelcache entity, and both directions of the mapper inMappers.swift. - Bump a document's
currentSchemawhen 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 theICloudFileManageractor.
UI
- SwiftUI-first:
NavigationStack,@Query-backed lists, form-based add/edit, sheet presentations, and swipe actions. - Theming via
Color+Extensions.swift; date formatting viaDate+Extensions.swift(both inShared/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:
- No preamble: the file starts directly with the first month heading — no title or intro line (e.g. Changelog / "All notable changes…").
- 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-aboutskill 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. In particular, never log changes to the changelog itself (its preamble, formatting, or how it's derived) — these have zero end-user value.
- 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.