Restructure into a three-tab app with Progress, goals, and Meditation

The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView
becomes a Today / Progress / Settings TabView, "Routine" replaces
"Split" in every user-facing string and view name (code-level types
keep their names), and workout starting moves to shared
WorkoutStarter / StartedWorkoutNavigator plumbing.

- New Progress tab: weekly goal streaks, workout trends, per-exercise
  weight progression, achievements, and the full history list
  (WorkoutLogsView -> WorkoutHistoryView).
- Goals: stable categories workouts roll up to, managed from Settings.
- New Meditation exercise + starter routine; timed sits record to
  Apple Health as Mind & Body sessions.

Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
This commit is contained in:
2026-07-11 07:53:01 -04:00
parent 5c201289fb
commit 6e440317c4
97 changed files with 5354 additions and 1291 deletions
@@ -34,9 +34,9 @@ final class WatchConnectivityBridge: NSObject {
/// Exclusive-edit lock pushed by the phone. While set, the watch parks the matching
/// run (popping out of its progress view) and blocks re-entry, so the phone owns the
/// edit and the watch can't clobber it with a stale optimistic write. `editingWorkoutID`
/// matches a run by its workout id; `editingSplitID` matches any run by its `splitID`.
/// matches a run by its workout id; `editingRoutineID` matches any run by its `routineID`.
private(set) var editingWorkoutID: String?
private(set) var editingSplitID: String?
private(set) var editingRoutineID: String?
/// Monotonic sequence stamped on each live-run frame we send. Bumped to stay ahead of any
/// frame we *receive*, so the two devices share one increasing per-run sequence and either
@@ -101,16 +101,16 @@ final class WatchConnectivityBridge: NSObject {
}
/// Apply the last application context the system holds for us (settings, edit
/// locks, and the authoritative splits + workouts sets). Idempotent, so it's safe
/// locks, and the authoritative routines + workouts sets). Idempotent, so it's safe
/// to call both eagerly at launch and again when activation completes.
private func applyReceivedContext() {
guard let session else { return }
let ctx = session.receivedApplicationContext
Self.log.info("applyReceivedContext: activation=\(session.activationState.rawValue) keys=\(ctx.keys.sorted().joined(separator: ","), privacy: .public)")
applyState(WCPayload.decodeSplits(ctx), workouts: WCPayload.decodeWorkouts(ctx))
applyState(WCPayload.decodeRoutines(ctx), workouts: WCPayload.decodeWorkouts(ctx))
applySettings(ctx)
editingWorkoutID = WCPayload.decodeEditingWorkoutID(ctx)
editingSplitID = WCPayload.decodeEditingSplitID(ctx)
editingRoutineID = WCPayload.decodeEditingRoutineID(ctx)
}
/// Apply a decoded state push. `nil` (decode failure the phone runs a build with
@@ -118,14 +118,14 @@ final class WatchConnectivityBridge: NSObject {
/// against a bogus empty set nor silently show stale data forever. The upsert/prune
/// itself is delegated to the pure, session-free `WatchCacheApplier` seam so the
/// apply/prune contract is unit-testable.
private func applyState(_ splits: [SplitDocument]?, workouts: [WorkoutDocument]?) {
guard WatchCacheApplier.apply(splits: splits, workouts: workouts, into: context) else {
Self.log.error("applyState: payload failed to decode (splits=\(splits == nil ? "failed" : "ok", privacy: .public), workouts=\(workouts == nil ? "failed" : "ok", privacy: .public)) — phone/watch build mismatch?")
private func applyState(_ routines: [RoutineDocument]?, workouts: [WorkoutDocument]?) {
guard WatchCacheApplier.apply(routines: routines, workouts: workouts, into: context) else {
Self.log.error("applyState: payload failed to decode (routines=\(routines == nil ? "failed" : "ok", privacy: .public), workouts=\(workouts == nil ? "failed" : "ok", privacy: .public)) — phone/watch build mismatch?")
schemaMismatch = true
return
}
schemaMismatch = false
Self.log.info("applyState: applied \(splits?.count ?? 0) splits, \(workouts?.count ?? 0) workouts")
Self.log.info("applyState: applied \(routines?.count ?? 0) routines, \(workouts?.count ?? 0) workouts")
lastSyncDate = Date()
onWorkoutsChanged?()
}
@@ -277,7 +277,7 @@ final class WatchConnectivityBridge: NSObject {
// MARK: - Cache apply/prune seam
/// The pure, session-free core of the phonewatch state apply: it upserts the authoritative
/// splits/workouts into the watch's SwiftData cache and prunes anything the phone no longer
/// routines/workouts into the watch's SwiftData cache and prunes anything the phone no longer
/// sends. Split out of `WatchConnectivityBridge` (which wraps `WCSession`) so the apply/prune
/// contract including the authoritative-empty prune and the nil-decode skip is unit-testable
/// against an in-memory `ModelContext` without a live WatchConnectivity session.
@@ -288,31 +288,31 @@ enum WatchCacheApplier {
/// applied, `false` when skipped, so the caller can log / stamp `lastSyncDate` accordingly.
@MainActor
@discardableResult
static func apply(splits: [SplitDocument]?, workouts: [WorkoutDocument]?, into context: ModelContext) -> Bool {
guard let splits, let workouts else { return false }
apply(splits: splits, workouts: workouts, into: context)
static func apply(routines: [RoutineDocument]?, workouts: [WorkoutDocument]?, into context: ModelContext) -> Bool {
guard let routines, let workouts else { return false }
apply(routines: routines, workouts: workouts, into: context)
return true
}
/// Upsert every split/workout the phone sent, then prune anything it *didn't*. Both sets are
/// Upsert every routine/workout the phone sent, then prune anything it *didn't*. Both sets are
/// authoritative, so an authoritative empty push clears rows the phone no longer has (a deleted
/// split; a run discarded/deleted on the phone, completed-and-aged-out, or otherwise dropped
/// routine; a run discarded/deleted on the phone, completed-and-aged-out, or otherwise dropped
/// from the ~24h window). On first launch the cache is empty, so the prune is a harmless no-op.
/// The watch never originates a split/workout, so pruning can't lose local-only data.
/// The watch never originates a routine/workout, so pruning can't lose local-only data.
@MainActor
static func apply(splits: [SplitDocument], workouts: [WorkoutDocument], into context: ModelContext) {
var liveSplitIDs = Set<String>()
for s in splits {
CacheMapper.upsertSplit(s, relativePath: s.relativePath, into: context)
liveSplitIDs.insert(s.id)
static func apply(routines: [RoutineDocument], workouts: [WorkoutDocument], into context: ModelContext) {
var liveRoutineIDs = Set<String>()
for s in routines {
CacheMapper.upsertRoutine(s, relativePath: s.relativePath, into: context)
liveRoutineIDs.insert(s.id)
}
var liveWorkoutIDs = Set<String>()
for w in workouts {
CacheMapper.upsertWorkout(w, relativePath: w.relativePath, into: context)
liveWorkoutIDs.insert(w.id)
}
if let allSplits = try? context.fetch(FetchDescriptor<Split>()) {
for s in allSplits where !liveSplitIDs.contains(s.id) { context.delete(s) }
if let allRoutines = try? context.fetch(FetchDescriptor<Routine>()) {
for s in allRoutines where !liveRoutineIDs.contains(s.id) { context.delete(s) }
}
if let allWorkouts = try? context.fetch(FetchDescriptor<Workout>()) {
for w in allWorkouts where !liveWorkoutIDs.contains(w.id) { context.delete(w) }
@@ -359,21 +359,21 @@ extension WatchConnectivityBridge: WCSessionDelegate {
}
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
let splits = WCPayload.decodeSplits(applicationContext)
let routines = WCPayload.decodeRoutines(applicationContext)
let workouts = WCPayload.decodeWorkouts(applicationContext)
let rest = WCPayload.decodeRestSeconds(applicationContext)
let done = WCPayload.decodeDoneCountdownSeconds(applicationContext)
let unit = WCPayload.decodeWeightUnit(applicationContext)
let editingWorkoutID = WCPayload.decodeEditingWorkoutID(applicationContext)
let editingSplitID = WCPayload.decodeEditingSplitID(applicationContext)
let editingRoutineID = WCPayload.decodeEditingRoutineID(applicationContext)
Task { @MainActor in
self.applyState(splits, workouts: workouts)
self.applyState(routines, workouts: workouts)
if let rest { UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey) }
if let done { UserDefaults.standard.set(done, forKey: WCPayload.doneCountdownSecondsKey) }
if let unit { UserDefaults.standard.set(unit, forKey: WCPayload.weightUnitKey) }
// Absent keys mean "not editing" set unconditionally so the lock clears.
self.editingWorkoutID = editingWorkoutID
self.editingSplitID = editingSplitID
self.editingRoutineID = editingRoutineID
}
}
}