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
@@ -5,7 +5,7 @@ import WatchConnectivity
/// Phone side of the iPhoneWatch bridge. The phone owns iCloud Drive; the watch
/// is a thin remote that round-trips through it:
/// Phone Watch: pushes all splits + recent workouts as the latest
/// Phone Watch: pushes all routines + recent workouts as the latest
/// application context whenever the cache changes (local or remote).
/// Watch Phone: receives an updated `WorkoutDocument` and applies it via the
/// SyncEngine write path (file observer cache push back).
@@ -19,12 +19,12 @@ final class PhoneConnectivityBridge: NSObject {
private var session: WCSession?
/// Exclusive-edit lock published to the watch. While the phone has a workout's
/// exercise (or a split) open in an editor, the watch parks any matching run and
/// exercise (or a routine) open in an editor, the watch parks any matching run and
/// blocks re-entry so the two devices never drive the same run at once. Included in
/// every `pushAll` (the latest-wins context replaces wholesale, so a push that omitted
/// them would clear the lock prematurely).
private(set) var editingWorkoutID: String?
private(set) var editingSplitID: String?
private(set) var editingRoutineID: String?
/// While true (scene backgrounded), the edit locks are *published* to the watch as
/// cleared without being forgotten locally. An editor left open in a pocketed or
@@ -70,7 +70,7 @@ final class PhoneConnectivityBridge: NSObject {
syncEngine.onCacheChanged = { [weak self] in self?.pushAll() }
}
/// Sends the current splits + most-recent workouts to the watch.
/// Sends the current routines + most-recent workouts to the watch.
func pushAll() {
guard let session, session.activationState == .activated, session.isPaired,
session.isWatchAppInstalled else {
@@ -78,7 +78,7 @@ final class PhoneConnectivityBridge: NSObject {
return
}
let splits = (try? context.fetch(FetchDescriptor<Split>(sortBy: [SortDescriptor(\.order)]))) ?? []
let routines = (try? context.fetch(FetchDescriptor<Routine>(sortBy: [SortDescriptor(\.order)]))) ?? []
// The watch only needs what it can act on: every active run (in-progress /
// not-started) plus recently-completed ones, kept ~24h so a run that just
@@ -107,24 +107,24 @@ final class PhoneConnectivityBridge: NSObject {
let restSeconds = UserDefaults.standard.object(forKey: WCPayload.restSecondsKey) as? Int ?? 45
let doneCountdownSeconds = UserDefaults.standard.object(forKey: WCPayload.doneCountdownSecondsKey) as? Int ?? 5
let weightUnit = UserDefaults.standard.string(forKey: WCPayload.weightUnitKey) ?? WeightUnit.lb.rawValue
let splitDocs = splits.map(SplitDocument.init(from:))
let routineDocs = routines.map(RoutineDocument.init(from:))
func encode(_ included: [Workout]) -> [String: Any] {
WCPayload.encodeState(
splits: splitDocs,
routines: routineDocs,
workouts: included.map(WorkoutDocument.init(from:)),
restSeconds: restSeconds,
doneCountdownSeconds: doneCountdownSeconds,
weightUnit: weightUnit,
editingWorkoutID: locksSuspended ? nil : editingWorkoutID,
editingSplitID: locksSuspended ? nil : editingSplitID
editingRoutineID: locksSuspended ? nil : editingRoutineID
)
}
do {
let payload = encode(workouts)
try session.updateApplicationContext(payload)
let bytes = ((payload[WCPayload.splitsKey] as? Data)?.count ?? 0)
let bytes = ((payload[WCPayload.routinesKey] as? Data)?.count ?? 0)
+ ((payload[WCPayload.workoutsKey] as? Data)?.count ?? 0)
Self.log.info("pushAll: \(splits.count) splits, \(workouts.count) workouts (\(bytes) bytes)")
Self.log.info("pushAll: \(routines.count) routines, \(workouts.count) workouts (\(bytes) bytes)")
} catch {
// Realistically payload-too-large. The context is the watch's lifeline a
// dropped push means it silently never hears about this state again so
@@ -145,7 +145,7 @@ final class PhoneConnectivityBridge: NSObject {
func setLocksSuspended(_ suspended: Bool) {
guard locksSuspended != suspended else { return }
locksSuspended = suspended
if editingWorkoutID != nil || editingSplitID != nil { pushAll() }
if editingWorkoutID != nil || editingRoutineID != nil { pushAll() }
}
/// Mark (or clear, with `nil`) the workout currently open in a phone exercise editor.
@@ -157,11 +157,11 @@ final class PhoneConnectivityBridge: NSObject {
pushAll()
}
/// Mark (or clear, with `nil`) the split currently open in a phone editor. The watch
/// parks any active run sourced from that split (matched by `splitID`).
func setEditingSplit(_ id: String?) {
guard editingSplitID != id else { return }
editingSplitID = id
/// Mark (or clear, with `nil`) the routine currently open in a phone editor. The watch
/// parks any active run sourced from that routine (matched by `routineID`).
func setEditingRoutine(_ id: String?) {
guard editingRoutineID != id else { return }
editingRoutineID = id
pushAll()
}