diff --git a/BULLETPROOFING.md b/BULLETPROOFING.md index 2bab141..e64868a 100644 --- a/BULLETPROOFING.md +++ b/BULLETPROOFING.md @@ -65,13 +65,17 @@ Timing variant: if B's session *does* start (old one already ended) but the tran **Fix direction:** clear the locks on phone `scenePhase` → background/inactive; scope the split lock to the actual edit sheets rather than the detail screen. -### M2 — Remote completion doesn't pop the watch's open exercise screen · open +### M2 — Remote completion doesn't pop the watch's open exercise screen · **fixed** + +> **Fix (2026-07-09):** both platforms' `ExerciseProgressView` now observe `log?.status` and dismiss when the log resolves to `.completed`/`.skipped` remotely (a `locallyResolved` flag keeps their own Done / flow hand-off from re-dismissing) — a stale open flow can no longer write a resolved log back to in-progress. The watch also gained the phone's `startsSkipped` terminal page (`SkippedPhaseView`), so opening a skipped exercise shows a static badge instead of a live, resurrectable flow. Gate-level popping was deliberately not added: with the screen-level dismissal the resurrection path is closed, and reviewing a just-completed run's list is legitimate. `popIfNavigatedRunUnavailable` pops on prune and on lock — but a run flipping to `.completed`/`.skipped` remotely **stays in the cache** (recently-completed runs are still pushed for ~24 h), so no pop fires. `repairFromDurable`'s safety net is gated on `status == .inProgress`, going silent exactly when needed. And the watch's `ExerciseProgressView` lacks the phone's `.skipped` handling, so a user sitting on a stale open exercise can keep ticking and write it back to a **non-terminal status** — resurrecting a skip through the per-log merge, since their edit is "newer". **Fix direction:** pop (or overlay a terminal state) when the navigated run leaves the active set, not only when it leaves the cache; port the phone's `.skipped` terminal handling to the watch's progress view. -### M3 — Follower mirror cover freezes in auto-advance splits · open +### M3 — Follower mirror cover freezes in auto-advance splits · **fixed** (freeze) / open (mid-set staleness) + +> **Fix (2026-07-09):** the terminal between-exercise rest now falls back to `dismiss()` when there's no `onAdvance` host (the live-mirror cover on both platforms) — the exercise still completes durably, the cover closes instead of freezing at 0:00, and the driver's next-exercise frame re-presents it. Residual: a cover sitting on a *count-up work page* when the driver dies still has no staleness timeout — acceptable because that surface is a take-over driver by design and dismissible by hand. The live-mirror cover (`LiveRunCoverView`, both platforms) is not a passive display — it runs the same local phase engine as a real driver, seeded from the last frame's anchors, and keeps advancing if the driver goes silent. Single-exercise runs self-heal (Finish auto-Done dismisses). But the cover is wired with `onAdvance: nil`, so in a flow-mode (auto-advance) split, when its local chain reaches the terminal between-exercise rest it completes the exercise (durable data stays correct) then **freezes at a 0:00 countdown** — the exercise hand-off exists only in `RunFlowView`, which the cover doesn't use. Related: if the driver dies without `sendLiveEnded`, the follower has **no staleness timeout** at all. @@ -89,9 +93,11 @@ The live-mirror cover (`LiveRunCoverView`, both platforms) is not a passive disp ## LOW -### L1 — Nondeterministic metrics attribution with parallel runs · open +### L1 — Nondeterministic metrics attribution with parallel runs · **fixed** -`SessionEndPlanner.decide` picks the completed survivor by iterating `previouslyActiveIDs` — a `Set`, so **iteration order is undefined** (`WorkoutSessionCoordinator.swift:49-51`). With two parallel runs finishing together, the `HKWorkout`'s metrics attach to an arbitrary one. Deterministic tie-break (e.g. most-recent `end`) would fix it. +`SessionEndPlanner.decide` picked the completed survivor by iterating `previouslyActiveIDs` — a `Set`, so iteration order was undefined. With two parallel runs finishing together, the `HKWorkout`'s metrics attached to an arbitrary one. + +> **Fix (2026-07-09):** `decide` now picks the most recently started completed survivor, ties broken by id (landed with the H-tier session-lifecycle commit; pinned by `parallelCompletions_pickMostRecentlyStarted`). ### L2 — `requestSync` failures vanish · open diff --git a/CHANGELOG.md b/CHANGELOG.md index a0452e8..a97f6fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ Putting the iPhone away with an editor still open no longer leaves the workout s Syncing to the watch no longer silently stops when a large workout history makes the update too big to send. +An exercise finished or ended early on one device now closes its still-open timer screen on the other, instead of the stale screen reviving the exercise. + +Opening a skipped exercise on the watch now shows a gray Skipped badge instead of dropping back into its timers. + +The mirrored workout view no longer freezes on a spent rest countdown when the other device drives an auto-advancing split. + A new Cardio exercise and matching starter split let you log aerobic sessions, tracked on your Apple Watch as a cardio workout. A new Auto-Advance option lets a split flow hands-free from one exercise to the next, resting automatically between them. diff --git a/Workouts Watch App/Views/ExerciseProgressView.swift b/Workouts Watch App/Views/ExerciseProgressView.swift index 07bda4f..c2b7d0d 100644 --- a/Workouts Watch App/Views/ExerciseProgressView.swift +++ b/Workouts Watch App/Views/ExerciseProgressView.swift @@ -105,6 +105,16 @@ struct ExerciseProgressView: View { /// so completing the exercise from inside the flow doesn't swap the page mid-dismiss. @State private var startsCompleted: Bool + /// True when the exercise was skipped (workout ended early) when this screen opened — + /// it shows a static Skipped page instead of the timer flow. Without it, opening a + /// skipped log dropped into the live flow, whose first recorded set would write the + /// log back to in-progress — resurrecting a resolved run through the merge. + @State private var startsSkipped: Bool + + /// Set when *this* screen resolves the exercise (Done / flow hand-off), so the + /// remote-flip observer below doesn't mistake our own terminal write for the phone's. + @State private var locallyResolved = false + /// Forces the starting page (used only by the DEBUG screenshot host). When set it /// also suppresses the Ready page so the index is a plain work/rest cycle offset. private let debugInitialPage: Int? @@ -133,6 +143,7 @@ struct ExerciseProgressView: View { let ready = debugInitialPage == nil && !enteredViaFlow _startsResumed = State(initialValue: ready && !notStarted) _startsCompleted = State(initialValue: ready && log?.status == WorkoutStatus.completed.rawValue) + _startsSkipped = State(initialValue: ready && log?.status == WorkoutStatus.skipped.rawValue) let base = ready ? 1 : 0 // Resume on the first unfinished set's work page (clamped to the last set). @@ -241,6 +252,8 @@ struct ExerciseProgressView: View { var body: some View { if startsCompleted { CompletedPhaseView() + } else if startsSkipped { + SkippedPhaseView() } else { flowBody } @@ -311,6 +324,16 @@ struct ExerciseProgressView: View { .onChange(of: log?.currentStateIndex) { _, _ in repairFromDurable() } + .onChange(of: log?.status) { _, raw in + // The log resolved out from under us (the run was completed or ended early on + // the phone). Leave the live flow — staying would let the next recorded set + // write a resolved log back to in-progress, resurrecting it through the merge. + // `locallyResolved` keeps our own Done / flow hand-off from re-dismissing. + guard didRestorePage, !locallyResolved, + let status = raw.flatMap(WorkoutStatus.init(rawValue:)), + status == .completed || status == .skipped else { return } + dismiss() + } .onAppear { guard !didRestorePage else { return } if startsResumed { @@ -500,8 +523,11 @@ struct ExerciseProgressView: View { completeExercise() // Re-resolve at fire time — the next exercise may have been // finished elsewhere while we rested. None left ⇒ end the run. - if let next = nextUnfinishedLogID { - onAdvance?(next) + // No hand-off host (the live-mirror cover) ⇒ close instead of + // freezing on a spent countdown; a follower re-presents on the + // driver's next-exercise frame. + if let next = nextUnfinishedLogID, let onAdvance { + onAdvance(next) } else { dismiss() } @@ -657,6 +683,7 @@ struct ExerciseProgressView: View { } private func completeExercise() { + locallyResolved = true knownStateIndex = max(knownStateIndex, setCount) guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } doc.logs[i].currentStateIndex = setCount @@ -883,6 +910,23 @@ private struct CompletedPhaseView: View { } } +// MARK: - Skipped Phase + +/// Shown instead of the run flow when the exercise was skipped (the workout was +/// ended early) — in gray, matching the list row's skipped icon. +private struct SkippedPhaseView: View { + var body: some View { + VStack(spacing: 8) { + Image(systemName: "forward.circle.fill") + .font(.system(size: 56)) + .foregroundStyle(.gray) + Text("Skipped") + .font(.system(.title3, design: .rounded, weight: .heavy)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + // MARK: - Ready Phase private struct ReadyPhaseView: View { diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index 6610f57..046b681 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -145,6 +145,10 @@ struct ExerciseProgressView: View { /// `startsCompleted`. @State private var startsSkipped: Bool + /// Set when *this* screen resolves the exercise (Done / flow hand-off), so the + /// remote-flip observer doesn't mistake our own terminal write for a peer's. + @State private var locallyResolved = false + init(doc: Binding, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, onActivity: @escaping (LiveProgress) -> Void = { _ in }, onActivityEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil, speechAnnouncer: SpeechAnnouncer? = nil, enteredViaFlow: Bool = false, onAdvance: ((String) -> Void)? = nil) { self._doc = doc self.logID = logID @@ -367,6 +371,16 @@ struct ExerciseProgressView: View { .onChange(of: log?.currentStateIndex) { _, _ in repairFromDurable() } + .onChange(of: log?.status) { _, raw in + // The log resolved out from under us (the run was completed or ended early on + // another device). Leave the live flow — staying would let the next recorded + // set write a resolved log back to in-progress, resurrecting it through the + // merge. `locallyResolved` keeps our own Done / flow hand-off from re-dismissing. + guard didRestorePage, !locallyResolved, + let status = raw.flatMap(WorkoutStatus.init(rawValue:)), + status == .completed || status == .skipped else { return } + dismiss() + } .onAppear { guard !didRestorePage else { return } if startsResumed { @@ -577,8 +591,11 @@ struct ExerciseProgressView: View { completeExercise() // Re-resolve at fire time — the next exercise may have been // finished elsewhere while we rested. None left ⇒ end the run. - if let next = nextUnfinishedLogID { - onAdvance?(next) + // No hand-off host (the live-mirror cover) ⇒ close instead of + // freezing on a spent countdown; a follower re-presents on the + // driver's next-exercise frame. + if let next = nextUnfinishedLogID, let onAdvance { + onAdvance(next) } else { dismiss() } @@ -804,6 +821,7 @@ struct ExerciseProgressView: View { } private func completeExercise() { + locallyResolved = true knownStateIndex = max(knownStateIndex, setCount) guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } doc.logs[i].currentStateIndex = setCount