Starter splits ship as byte-canonical SplitDocument JSON with fixed ULIDs (Workouts/Resources/StarterSplits, regenerated by Scripts/generate_starter_splits.swift) and auto-seed after connect into a verifiably empty container, re-checked after a settle delay — wrong guesses are harmless because identical bytes make same-path conflicts empty and tombstones reap resurrected seeds. Seeds are immutable: SyncEngine.save(split:) forks an edited seed to a fresh ULID and soft-deletes the original, whose stub is exempt from pruning (IndieSync 0.3.0 prune(exempting:)) and vetoes resurrection forever; split views resolve by id through a redirect map to follow the swap. Add Starter Splits in Settings restores deleted seeds by lifting the veto stub and rewriting the bundle bytes. Also fixes ingestFromWatch bypassing the tombstone veto (a phone-deleted workout resurrected when a stale watch resent it) and reaps a live file immediately when its tombstone arrives. SplitDetailView also picks up the category-grouped exercise sections from the exercise-category work.
10 KiB
10 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 →MetadataObserver(anNSMetadataQuery) emits a delta batch →CacheMapperupserts SwiftData →@Queryviews refresh. The phone is the sole writer of iCloud Drive. - Key types:
SyncEngine(@Observable @MainActor, inWorkouts/Sync/) — the app-specific orchestrator.connect()resolves the ubiquity container and reconciles;save/deletewrite files;handle(_:)applies observer delta batches;ingestFromWatch(_:)writes + upserts the cache directly. ExposesiCloudStatusand anonCacheChangedcallback.- The file-side core comes from the IndieSync SPM package (
https://git.rzen.dev/rzen/indie-sync.git):DocumentFileStore(actor — allNSFileCoordinatorI/O, conflict resolution, eviction-safe reads, placeholder-aware enumeration),TombstoneStore(soft-delete stubs, resurrection veto, grace-period pruning),MetadataObserver(@MainActor— wraps theNSMetadataQuery, emitting added/modified/removed batches as anAsyncStream), plusULID,DocumentCoder,SyncError, and theVersionedDocumentprotocol. See theicloud-sync-engineskill for the architecture invariants.
- 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 — except starter-seed stubs, which are exempt (prune(exempting:)) and veto seed resurrection forever. - 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]), conforming to IndieSync'sVersionedDocumentschema gate (quarantines files from newer app versions).TombstoneandDocumentCodercome from IndieSync.WorkoutDocument.relativePathkeeps the app's own local-calendar month bucketing (predates IndieSync's UTCTimeBucketedLayout; changing it would strand existing files). - 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) — a thin shim over IndieSync'sULID: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) 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 (git commit count),
BuildDate, andBuildHashare stamped by theapp-versioningskill'supdate_build_info.sh(referenced in place from../indie-skills, a post-build phase; release tagging is opt-in viaRELEASE_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),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 (deterministic seeds)
- Starter splits: shipped as byte-canonical
SplitDocumentJSON inWorkouts/Resources/StarterSplits/*.split.jsonwith fixed ULIDs (shared01DXF6DT00prefix, frozen 2020 timestamp) and fixed content, regenerated only byScripts/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 viasync.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 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
currentSchemaVersionwhen 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'sDocumentFileStoreactor.
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 — follow the app-changelog skill (single source of truth for format and workflow).