Files
workouts/CLAUDE.md
T
rzen 47a49cc356 Stagger build numbers by platform: iOS/watch even, macOS odd
Both platforms share one App Store Connect build-number space (same
bundle ID, universal purchase), so the raw commit count would collide.
The vendored script partitions on PLATFORM_NAME; watchOS rides with iOS
because the embedded watch app's CFBundleVersion must match its host.
2026-07-16 20:14:23 -04:00

13 KiB
Raw Blame History

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, Apple Watch, and Mac, built with Swift 6 and SwiftUI (iOS 26 / watchOS 26 / macOS 26). The Mac app is a library-management companion (routines, schedules, exercise browser — no workout running). 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 & Mac): view → SyncEngine.save(...) writes a file → MetadataObserver (an NSMetadataQuery) emits a delta batch → CacheMapper upserts SwiftData → @Query views refresh. The phone and the Mac both write iCloud Drive; the Mac writes only routine and schedule documents (whole-document last-writer-wins — it must never write workout documents, whose per-log merge exists only on the watch ingest path).
  • 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 RootGateViewContentViewWorkoutLogsView (a single screen — there is no TabView).
  • watchOS: WorkoutsWatchApp (@main) owns WatchAppServices (container + bridge; no iCloud, no SyncEngine) and a WatchAppDelegate. Root is ContentViewActiveWorkoutGateView.
  • macOS: WorkoutsMacApp (@main, in Workouts Mac/) owns MacAppServices (container + syncEngine only — no watch bridge, HealthKit, Live Activity, or backup). Root is MacRootGateViewMacContentView (a NavigationSplitView: routines sidebar, schedules, exercise browser). Reconciles on window activation (throttled) because iCloud can sync while the app isn't running; flushes pending writes on deactivation.
  • Views read services with @Environment(SyncEngine.self) (etc.), read data with @Query, and write with await sync.save(...) / delete(...).

Watch Sync (WatchConnectivity bridge)

  • The watch never touches iCloud Drive; it 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 / macOS 26; Swift 6 with SWIFT_STRICT_CONCURRENCY: complete on all targets.
  • Build number, BuildDate, and BuildHash are stamped by the vendored Scripts/update_build_info.sh (a post-build phase; release tagging is opt-in via RELEASE_TAGGING=1). Build numbers are platform-partitioned from the git commit count — iOS/watchOS get even (2×commits), macOS gets odd (2×commits+1) — because both platforms share one ASC build-number space and the embedded watch app must match the iOS app's CFBundleVersion. Scripts/ also holds the App Store / TestFlight release pipeline.
  • Entitlements: iOS uses CloudDocuments + HealthKit; watch uses HealthKit only; macOS uses app sandbox + CloudDocuments (same bundle ID as iOS — universal purchase). 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
  • Workouts Mac/ (macOS): WorkoutsMacApp, MacAppServices, Views/ (Routines/, Schedules/, Exercises/), Resources/; also compiles Workouts/Sync/, Workouts/Seed/, Workouts/ExerciseFigure/, and a few portable files from Workouts/Views/ (see project.yml)
  • 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 immutableSyncEngine.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 & reconcile: after connect + reconcile, SyncEngine.seedOrReconcile() (deferred, settle-delayed) branches on container state so the two seeders can never both fire. An empty container → autoSeedIfEmpty() writes the verbatim bundle bytes (re-checked after the settle delay). A non-empty container → reconcileSeeds() diffs each bundled seed against its fixed-ULID file via the pure SeedReconcilePlanner (Workouts/Sync/SeedReconcile.swift): a stale revision is overwritten with current bundle bytes (a semantic compare with schemaVersion normalized — never a byte compare, since older files carry now-removed keys), a newly-shipped seed with no file and no stub is written (unless a live same-name split already exists, or a newer app version wrote the file), and a deleted seed (stub present) is left vetoed. Wrong guesses are harmless — identical bytes make same-path conflicts empty, stubs reap resurrected seeds, and the veto + name guards are re-checked right before each write. This is safe because a fixed-ULID file can never hold user content (every edit forks to a fresh ULID).
  • Restore Starter Splits (Settings) is the one deliberate veto lift: SyncEngine.restoreSeeds() removes a deleted seed's stub and rewrites the current bundle bytes (never the stub's old contents), still skipping seeds whose live file already exists (reconcile handles upgrades) or whose name collides with a live split; it returns the count for a brief in-Settings confirmation.
  • 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/* resources — *.motion.json rigs, which double as the exercise picker's list (ExerciseMotionLibrary.exerciseNames), and *.info.md reference pages, parsed by ExerciseInfo and rendered in the Settings → Library → Exercises detail screen. Re-export both with python3 render.py --export.

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).