Files
workouts/CLAUDE.md

9.5 KiB
Raw Permalink 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 → ICloudFileMonitor (an NSMetadataQuery) emits a delta → CacheMapper upserts SwiftData → @Query views 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/delete write files; handle(event:) applies observer deltas; ingestFromWatch(_:) writes + upserts the cache directly. Exposes iCloudStatus and an onCacheChanged callback.
    • ICloudFileManager (actor) — all NSFileCoordinator file I/O, off the main thread.
    • ICloudFileMonitor (@MainActor) — wraps the NSMetadataQuery, emitting added/modified/removed deltas as an AsyncStream.
  • 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]), plus Tombstone, a VersionedDocument schema gate (quarantines files from newer app versions), and a shared DocumentCoder.
  • 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) — 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) 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: 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 currentSchema 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 the ICloudFileManager 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, 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-about skill 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 with substantive meaning for an end user — app behavior, new features, and the like. Skip anything else (internal / tooling / refactor / docs-only commits).
  • 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.