Files
workouts/CLAUDE.md
T
rzen 1f2df491db Migrate the sync file layer onto the IndieSync package
Replaces the app-local copies of the extracted storage core with the
IndieSync 0.1.0 package (pinned from the new tag): ICloudFileManager ->
DocumentFileStore + TombstoneStore, ICloudFileMonitor ->
MetadataObserver (batched events with the content-date churn gate),
and the shared ULID / DocumentCoder / Tombstone / VersionedDocument
pieces. SyncEngine stays app-specific and is rewired onto the package
types; documents rename currentSchema -> currentSchemaVersion to adopt
the package protocol. ULID.make() remains as a shim minting the string
form the app keys on.

Behavior-preserving: identical JSON bytes (same coder config), same
stub wire format (kind encodes as the same strings), same soft-delete
ordering, same 30-day prune. WorkoutDocument keeps its local-calendar
month bucketing rather than adopting TimeBucketedLayout's UTC paths --
changing derivation would strand existing files. The watch target
links the package too (ULID + DocumentCoder via Shared); iOS, watchOS,
and widget targets all build.
2026-07-05 08:04:29 -04:00

9.2 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 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 → MetadataObserver (an NSMetadataQuery) emits a delta batch → CacheMapper upserts SwiftData → @Query views refresh. The phone is the sole writer of iCloud Drive.
  • 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.
  • 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.
  • 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: IndieSync (file-side iCloud sync core), 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 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).