Files
workouts/CLAUDE.md
T
rzen cbdf02bca7 Add the macOS app: a library-management companion (routines, schedules, exercise browser)
A new 'Workouts Mac' target (same bundle ID as iOS, universal purchase)
with a NavigationSplitView shell over the shared data/sync layers:
routine management with starter gallery and seed-fork follow, schedule
editing (reminders stay iPhone-scheduled), and a browse-only exercise
library with the animated figures and reference guides.

Multi-writer stance: the Mac writes only routine and schedule documents
(whole-document last-writer-wins) and never workout documents, whose
per-log merge exists only on the watch ingest path. The Mac reconciles
on window activation (throttled) since iCloud syncs while the app is
closed, and flushes pending writes on deactivation.
2026-07-16 19:55:06 -04:00

13 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, 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 (git commit count), BuildDate, and BuildHash are stamped by the app-versioning skill's update_build_info.sh (referenced in place from ../indie-skills, a post-build phase; release tagging is opt-in via RELEASE_TAGGING=1). Scripts/ 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).