Files
workouts/Workouts Watch App/WorkoutSessionCoordinator.swift
T
rzen 6e440317c4 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
2026-07-11 07:53:01 -04:00

182 lines
9.6 KiB
Swift

//
// WorkoutSessionCoordinator.swift
// Workouts Watch App
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
import HealthKit
import SwiftData
/// What the watch should do with its `HKWorkoutSession` given the latest authoritative
/// workout set. `finish` carries the completed run whose metrics we save + forward.
enum SessionEndDecision: Equatable {
case none
case discard
case finish(WorkoutDocument)
}
/// Pure decision seam for the watch's workout-session lifecycle — the session-free core the
/// `WorkoutSessionCoordinator` delegates to, mirroring the `WatchCacheApplier` pattern so the
/// start/end rules are unit-testable without HealthKit or a live cache.
///
/// The end rule reconciles against the *authoritative* workout set (the same set the phone
/// pushes and the watch prunes to), not against any view's lifetime — a running session ends
/// on a genuine non-empty → empty transition of the active set. Comparing against the active
/// set captured at the *previous* reconcile is what makes the launch race safe: at startup the
/// session can be `running` before the run document has synced, but the previous active set is
/// still empty, so we never discard a run we simply haven't heard about yet.
///
/// The start rule makes the watch own its session: whenever the authoritative set holds an
/// active run and no session is running, one should be started. The phone's
/// `startWatchApp(toHandle:)` handoff then becomes a fast-launch optimization rather than the
/// only way a session is ever born — a dropped handoff (watch unreachable at start, a handoff
/// swallowed by `start`'s idempotency guard while the old run's session was still running),
/// a crash/reboot, or a run engaged manually on the wrist all heal at the next reconcile.
enum SessionEndPlanner {
/// A `.finish` for a session younger than this is demoted to `.discard`: a seconds-old
/// session has no meaningful samples, and the demotion is what keeps the two stale-data
/// races from polluting Apple Health with junk workouts attributed to the wrong run —
/// (a) a transient "old run completed, new run not yet pushed" context arriving right
/// after the new run's session started, and (b) a self-start off stale cache at launch,
/// corrected seconds later by the fresh context. In both cases the active set refills
/// and `shouldStart` brings the session right back.
static let minimumFinishAge: TimeInterval = 30
/// Ids of the runs that keep the session alive — not-yet-finished (`notStarted` /
/// `inProgress`). A `completed` / `skipped` run has dropped off the active set.
static func activeIDs(in workouts: [WorkoutDocument]) -> Set<String> {
Set(workouts
.filter {
let status = WorkoutStatus(rawValue: $0.status)
return status == .notStarted || status == .inProgress
}
.map(\.id))
}
/// True when a session should be running but isn't — the watch self-starts one rather
/// than depending on the phone's one-shot launch handoff having arrived.
static func shouldStart(isRunning: Bool, workouts: [WorkoutDocument]) -> Bool {
!isRunning && !activeIDs(in: workouts).isEmpty
}
/// The run whose routine configures a self-started session: the most recently started
/// active one (ties broken by id for determinism).
static func runToStart(in workouts: [WorkoutDocument]) -> WorkoutDocument? {
let ids = activeIDs(in: workouts)
return workouts
.filter { ids.contains($0.id) }
.max { a, b in a.start == b.start ? a.id < b.id : a.start < b.start }
}
/// `sessionAge` is seconds since the running session started (nil when unknown — e.g. a
/// recovered session, which is by definition old enough to finish normally).
static func decide(isRunning: Bool,
sessionAge: TimeInterval?,
previouslyActiveIDs: Set<String>,
workouts: [WorkoutDocument]) -> SessionEndDecision {
guard isRunning,
activeIDs(in: workouts).isEmpty,
!previouslyActiveIDs.isEmpty else { return .none }
// A previously-active run that survived as `.completed` is a finish (save + forward
// metrics); if none survived, every active run vanished (discarded/deleted) → discard.
// Among several completed survivors (parallel runs), take the most recently started —
// deterministic, and the one whose lifetime best matches the session's.
let finished = previouslyActiveIDs
.compactMap { id in workouts.first { $0.id == id } }
.filter { WorkoutStatus(rawValue: $0.status) == .completed }
.max { a, b in a.start == b.start ? a.id < b.id : a.start < b.start }
guard let finished else { return .discard }
// Too young to be a real finish — see `minimumFinishAge`.
if let sessionAge, sessionAge < minimumFinishAge { return .discard }
return .finish(finished)
}
}
/// The single owner of the watch's workout-session lifecycle — both ends of it. Long-lived
/// (held by `WatchAppServices`, not a view), so it runs on the WatchConnectivity delegate
/// callback — with or without a mounted view, including in the session's own background
/// runtime. Termination here is the fix for the leak where ending a run *from the phone*
/// while the watch app was backgrounded left the `HKWorkoutSession` running (watchOS then
/// re-foregrounded the app on every wrist raise) and skipped the metrics capture entirely.
/// Self-starting here is the counterpart fix: a session is started whenever the authoritative
/// set holds an active run and none is running, so a dropped phone launch handoff (or a
/// crash, or a run engaged manually on the wrist) can't leave a workout sessionless — no HR,
/// no Health save, app suspending wrist-down.
///
/// Same move as the live-mirror's `repairFromDurable`, one layer down: drive the terminal
/// action from the authoritative cache apply rather than a transient UI observer.
@MainActor
final class WorkoutSessionCoordinator {
private let sessionManager: WorkoutSessionManager
private let bridge: WatchConnectivityBridge
private let context: ModelContext
/// The active set captured at the previous reconcile — the baseline the non-empty → empty
/// transition is measured against (see `SessionEndPlanner`).
private var previouslyActiveIDs: Set<String> = []
/// Guards the async finish so a burst of reconciles can't double-end the session.
private var isEnding = false
init(sessionManager: WorkoutSessionManager, bridge: WatchConnectivityBridge, context: ModelContext) {
self.sessionManager = sessionManager
self.bridge = bridge
self.context = context
}
/// Re-evaluate the session against the current authoritative cache. Called after every
/// cache mutation (phone push or the watch's own optimistic edit) and once at bootstrap to
/// seed the baseline. The watch cache holds only active + recently-pruned runs, so the
/// fetch-and-map is bounded and cheap.
func reconcile() {
guard !isEnding else { return }
let workouts = ((try? context.fetch(FetchDescriptor<Workout>())) ?? [])
.map(WorkoutDocument.init(from:))
let decision = SessionEndPlanner.decide(
isRunning: sessionManager.isRunning,
sessionAge: sessionManager.sessionStartDate.map { -$0.timeIntervalSinceNow },
previouslyActiveIDs: previouslyActiveIDs,
workouts: workouts)
previouslyActiveIDs = SessionEndPlanner.activeIDs(in: workouts)
switch decision {
case .none: startIfNeeded(workouts: workouts)
case .discard: sessionManager.discard()
case .finish(let doc): endAndForward(doc)
}
}
/// Self-start a session when an active run has none (see `SessionEndPlanner.shouldStart`).
/// The activity type comes from the run's routine so the saved Health workout is categorized
/// the same way a phone-launched one would be; a run whose routine is gone (deleted, or not
/// yet synced) falls back to traditional strength training.
private func startIfNeeded(workouts: [WorkoutDocument]) {
guard SessionEndPlanner.shouldStart(isRunning: sessionManager.isRunning, workouts: workouts),
let run = SessionEndPlanner.runToStart(in: workouts) else { return }
let routine = run.routineID.flatMap { id in
(try? context.fetch(FetchDescriptor<Routine>())).flatMap { $0.first { $0.id == id } }
}
let configuration = HKWorkoutConfiguration()
configuration.activityType = routine?.activityTypeEnum.hkActivityType ?? .traditionalStrengthTraining
configuration.locationType = .indoor
sessionManager.start(with: configuration)
}
/// Finish the session (saving the `HKWorkout` to Health), attach the captured metrics, and
/// forward the run back to the phone. Lifted from the old `ActiveWorkoutGateView.endSession`
/// so the behavior is unchanged — only its trigger moved off the view.
private func endAndForward(_ doc: WorkoutDocument) {
isEnding = true
Task {
defer { isEnding = false }
guard var metrics = await sessionManager.finishAndSave() else { return }
metrics.totalVolume = WorkoutVolume.total(doc.logs)
var updated = doc
updated.metrics = metrics
updated.updatedAt = Date()
bridge.update(workout: updated) // optimistic upsert + forward (queued if offline)
}
}
}