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** **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". 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. 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. /// remote-flip observer below doesn't mistake our own terminal write for the phone's.
@State private var locallyResolved = false @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 /// 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. /// also suppresses the Ready page so the index is a plain work/rest cycle offset.
private let debugInitialPage: Int? private let debugInitialPage: Int?
@@ -156,6 +160,18 @@ struct ExerciseProgressView: View {
let completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1) let completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1)
let resume = base + completed * 2 let resume = base + completed * 2
_currentPage = State(initialValue: debugInitialPage ?? (notStarted ? 0 : resume)) _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? { 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 /// 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) { 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) 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 /// 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). /// (so a re-assert after a TabView snap-to-0 is a no-op in the common case).
private func jumpToResumePage() { private func jumpToResumePage() {
let target = resumePage let target = resumeTargetPage ?? resumePage
guard currentPage != target else { return } guard currentPage != target else { return }
var transaction = Transaction() var transaction = Transaction()
transaction.disablesAnimations = true transaction.disablesAnimations = true