From c39eeda9df390de10231d5ef6d727536ccc16dcc Mon Sep 17 00:00:00 2001 From: rzen Date: Tue, 14 Jul 2026 22:01:49 -0400 Subject: [PATCH] Port the exercise run screen's true resume to the watch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-entering an in-progress exercise on the watch now computes its landing (page + wall-clock anchor) from the log's durable timestamps in init — mid-set resumes the stopwatch from startedAt, mid-rest lands on the rest page with the countdown continuing and auto-advancing at the true boundary, and a spent rest anchors the next set at the rest's computed end. Timestamps are clamped to now against peer clock skew; legacy logs without them keep the old first-unfinished-set behavior. broadcastLive forwards the resume anchor so a mirroring phone's timer lines up. Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV --- CHANGELOG.md | 2 + .../Views/ExerciseProgressView.swift | 68 ++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da246f9..33f8d38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ **July 2026** +Reopening an exercise on the watch now also continues its timer where it left off — even mid-rest — instead of restarting from zero. + A renamed exercise's headline now shows both identities on one line, like "Cardio · Warm Up". Renamed exercises in workouts recorded by older versions get their animated figure and guide back. diff --git a/Workouts Watch App/Views/ExerciseProgressView.swift b/Workouts Watch App/Views/ExerciseProgressView.swift index 1d41fae..0250d69 100644 --- a/Workouts Watch App/Views/ExerciseProgressView.swift +++ b/Workouts Watch App/Views/ExerciseProgressView.swift @@ -121,6 +121,10 @@ struct ExerciseProgressView: View { /// remote-flip observer below doesn't mistake our own terminal write for the phone's. @State private var locallyResolved = false + /// Where the resume landed, computed once at init (with its timer anchor) so the + /// post-layout re-assert jumps to the same page the anchor was built for. + @State private var resumeTargetPage: Int? + /// 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? @@ -156,6 +160,18 @@ struct ExerciseProgressView: View { let completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1) let resume = base + completed * 2 _currentPage = State(initialValue: debugInitialPage ?? (notStarted ? 0 : resume)) + + // A resumed run refines the landing to the phase the exercise is actually in + // (mid-rest lands on the rest page) with its wall-clock anchor, so the timer + // continues instead of restarting. Computed here — not on appear — because the + // paged TabView must *initialize* on this page: a post-layout backward jump + // wedges its pager, which then ignores the next animated programmatic advance. + if ready, !notStarted, log?.status == WorkoutStatus.inProgress.rawValue { + let refined = computeResume() + _currentPage = State(initialValue: refined.page) + _resumeTargetPage = State(initialValue: refined.page) + _pageAnchor = State(initialValue: refined.anchor) + } } private var log: WorkoutLogDocument? { @@ -407,9 +423,14 @@ struct ExerciseProgressView: View { } /// Push the current flow position to a mirroring iPhone. The anchor is stamped *now* — the - /// page just became active — so the mirror's timer lines up with this device's. + /// page just became active — except when the page carries an anchor (a resumed run + /// continuing its phase), which is forwarded so the phone's mirror timer lines up with ours. private func broadcastLive(for page: Int) { - guard debugInitialPage == nil, let snapshot = liveSnapshot(for: page) else { return } + guard debugInitialPage == nil, var snapshot = liveSnapshot(for: page) else { return } + if let anchor = pageAnchor, anchor.page == page { + snapshot.phaseStart = anchor.start + snapshot.phaseEnd = anchor.end + } onLive(snapshot) } @@ -516,10 +537,51 @@ struct ExerciseProgressView: View { } } + /// Where a resumed run re-opens: the page plus the wall-clock anchor that lets its + /// timer continue from where the exercise actually is, rebuilt from the durable + /// timestamps (there is no ephemeral state to restore — leaving the screen tears the + /// live flow down). + /// + /// • No sets recorded yet → the first work page, its stopwatch anchored to + /// `startedAt` so it keeps counting. + /// • k sets recorded and the rest after set k is still running (the k-th entry is + /// stamped as its rest begins — see `recordProgress`) → that rest page, anchored + /// to its true window so the countdown resumes mid-flight and still auto-advances + /// at the real boundary. + /// • Otherwise → set k+1's work page, anchored at the rest's computed end. + /// + /// Logs without the timestamps (older files) fall back to today's behavior: the + /// first unfinished set's work page, self-anchored at local `now`. + private func computeResume(now: Date = Date()) -> (page: Int, anchor: PageAnchor?) { + let fallback = resumePage + guard let log else { return (fallback, nil) } + let completed = min(max(0, log.currentStateIndex), setCount - 1) + + func workAnchor(page: Int, start: Date) -> PageAnchor { + PageAnchor(page: page, start: start, + end: isDuration ? start.addingTimeInterval(Double(workDurationSeconds)) : nil) + } + + if completed == 0 { + guard let started = log.startedAt else { return (fallback, nil) } + // Clamp to `now` so a peer's clock skew can't stamp a future start and + // make the stopwatch (or a timed set's window) run long. + return (fallback, workAnchor(page: fallback, start: min(started, now))) + } + guard let entries = log.setEntries, entries.count >= completed else { return (fallback, nil) } + let restStart = min(entries[completed - 1].completedAt, now) + let restEnd = restStart.addingTimeInterval(Double(effectiveRest)) + if now < restEnd { + let restPage = base + (completed - 1) * 2 + 1 + return (restPage, PageAnchor(page: restPage, start: restStart, end: restEnd)) + } + return (fallback, workAnchor(page: fallback, start: restEnd)) + } + /// Move to the resume page without animation, only if we're not already there /// (so a re-assert after a TabView snap-to-0 is a no-op in the common case). private func jumpToResumePage() { - let target = resumePage + let target = resumeTargetPage ?? resumePage guard currentPage != target else { return } var transaction = Transaction() transaction.disablesAnimations = true