Port the exercise run screen's true resume to the watch

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
This commit is contained in:
2026-07-14 22:01:49 -04:00
parent 4cd6e4181c
commit c39eeda9df
2 changed files with 67 additions and 3 deletions
+2
View File
@@ -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.
@@ -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