Anchor mirrored live-run timers to the sender's wall clock

The two-way live run unified the propped-phone mirror onto the real
ExerciseProgressView driver, whose phase views anchor their timers to a
local Date() captured when the page becomes active. For a remote-driven
transition that meant anchoring at phaseStart + delivery-latency +
render-time, so the receiver's countdown lagged the driver — visibly so
iPhone->watch, where the watch is the slower receiver (apply -> SwiftUI
re-render -> TabView animation -> phase view activates).

The frame already carries phaseStart/phaseEnd wall-clock anchors (the way
the old read-only mirror counted off them). Honor them: when a phase is
reached by applying a remote frame, the active phase view anchors its
timer to the frame's phaseStart/phaseEnd instead of local now. Scoped to
the remote-driven page (remoteAnchorPage) so a local swipe or auto-advance
still self-anchors; any local transition clears it. Applied symmetrically
to both the iPhone and watch drivers.

Delivery latency no longer shifts the displayed timer in either direction
(limited only by sub-second inter-device clock skew), and the auto-advance
at zero fires together since both share the same phaseEnd.

Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
This commit is contained in:
2026-06-20 22:31:51 -04:00
parent 192aa6f95a
commit b2d670724a
2 changed files with 132 additions and 36 deletions
@@ -64,6 +64,14 @@ struct ExerciseProgressView: View {
/// Highest remote frame version we've applied, so a redelivery doesn't re-jump. /// Highest remote frame version we've applied, so a redelivery doesn't re-jump.
@State private var lastAppliedVersion = 0 @State private var lastAppliedVersion = 0
/// When a remote frame drove us to a page, that page's timer anchors to the *sender's*
/// wall clock (`remoteAnchorStart`/`End`) instead of local `now` so delivery latency
/// can't desync the mirror, the way the old read-only mirror counted off the frame's
/// anchors. Scoped to `remoteAnchorPage`; any local transition to another page self-anchors.
@State private var remoteAnchorPage: Int?
@State private var remoteAnchorStart: Date?
@State private var remoteAnchorEnd: Date?
private enum PageChangeCause { case auto, remote } private enum PageChangeCause { case auto, remote }
/// True when this run opened on a resumed set (in-progress) rather than the Ready /// True when this run opened on a resumed set (in-progress) rather than the Ready
@@ -213,11 +221,13 @@ struct ExerciseProgressView: View {
case .auto: case .auto:
// Rest / timed-work auto-advance: record forward progress, but don't // Rest / timed-work auto-advance: record forward progress, but don't
// broadcast the mirror reaches this point on its own synchronized timer. // broadcast the mirror reaches this point on its own synchronized timer.
clearRemoteAnchor()
recordProgress(for: newPage) recordProgress(for: newPage)
case .none: case .none:
// A human swipe. Swiping back from the first set to Ready wipes the run // A human swipe. Swiping back from the first set to Ready wipes the run
// (only the adjacent 10 swipe resets a stray far jump never does); any // (only the adjacent 10 swipe resets a stray far jump never does); any
// other page records forward progress. Human transitions are broadcast. // other page records forward progress. Human transitions are broadcast.
clearRemoteAnchor()
if showsReady && newPage == 0 && oldPage == base { if showsReady && newPage == 0 && oldPage == base {
resetExercise() resetExercise()
} else { } else {
@@ -281,11 +291,24 @@ struct ExerciseProgressView: View {
lastAppliedVersion = frame.version lastAppliedVersion = frame.version
if frame.setCount != setCount { setCount = frame.setCount } if frame.setCount != setCount { setCount = frame.setCount }
let target = page(forPhase: frame.phase, setIndex: frame.setIndex) let target = page(forPhase: frame.phase, setIndex: frame.setIndex)
// Anchor the target page's timer to the sender's wall clock, not local now, so the
// displayed countdown matches regardless of how long the frame took to arrive.
remoteAnchorPage = target
remoteAnchorStart = frame.phaseStart
remoteAnchorEnd = frame.phaseEnd
guard target != currentPage else { return } guard target != currentPage else { return }
pageChangeCause = .remote pageChangeCause = .remote
withAnimation { currentPage = target } withAnimation { currentPage = target }
} }
/// Drop the remote anchor when a local transition moves us off the remote-driven page,
/// so the next phase counts off local `now` (and a swipe back doesn't reuse a stale anchor).
private func clearRemoteAnchor() {
remoteAnchorPage = nil
remoteAnchorStart = nil
remoteAnchorEnd = nil
}
/// Inverse of `liveSnapshot`'s pageframe mapping: a frame's phase/set page index. /// Inverse of `liveSnapshot`'s pageframe mapping: a frame's phase/set page index.
private func page(forPhase phase: LiveRunPhase, setIndex: Int) -> Int { private func page(forPhase phase: LiveRunPhase, setIndex: Int) -> Int {
let set = min(max(0, setIndex), max(0, setCount - 1)) let set = min(max(0, setIndex), max(0, setCount - 1))
@@ -349,6 +372,10 @@ struct ExerciseProgressView: View {
@ViewBuilder @ViewBuilder
private func page(for index: Int) -> some View { private func page(for index: Int) -> some View {
let isActive = index == currentPage let isActive = index == currentPage
// Only the remote-driven page carries the sender's anchors; every other page (reached
// locally by swipe or auto-advance) counts off its own `now`.
let anchorStart = index == remoteAnchorPage ? remoteAnchorStart : nil
let anchorEnd = index == remoteAnchorPage ? remoteAnchorEnd : nil
if showsReady && index == 0 { if showsReady && index == 0 {
ReadyPhaseView(summary: readySummary, onStart: start) ReadyPhaseView(summary: readySummary, onStart: start)
} else { } else {
@@ -358,7 +385,8 @@ struct ExerciseProgressView: View {
FinishPhaseView( FinishPhaseView(
isActive: isActive, isActive: isActive,
onDone: { completeExercise(); dismiss() }, onDone: { completeExercise(); dismiss() },
onOneMore: addSet onOneMore: addSet,
anchorEnd: anchorEnd
) )
} else if cycleIndex.isMultiple(of: 2) { } else if cycleIndex.isMultiple(of: 2) {
let setNumber = cycleIndex / 2 + 1 let setNumber = cycleIndex / 2 + 1
@@ -369,7 +397,9 @@ struct ExerciseProgressView: View {
header: "\(setNumber) of \(setCount)", header: "\(setNumber) of \(setCount)",
tint: .workTimer, tint: .workTimer,
seconds: workDurationSeconds, seconds: workDurationSeconds,
isActive: isActive isActive: isActive,
anchorStart: anchorStart,
anchorEnd: anchorEnd
) { ) {
withAnimation { advance(from: index) } withAnimation { advance(from: index) }
} }
@@ -379,7 +409,8 @@ struct ExerciseProgressView: View {
setNumber: setNumber, setNumber: setNumber,
totalSets: setCount, totalSets: setCount,
detail: detail, detail: detail,
isActive: isActive isActive: isActive,
anchorStart: anchorStart
) )
} }
} else { } else {
@@ -388,7 +419,9 @@ struct ExerciseProgressView: View {
header: "Rest", header: "Rest",
tint: .restTimer, tint: .restTimer,
seconds: restSeconds, seconds: restSeconds,
isActive: isActive isActive: isActive,
anchorStart: anchorStart,
anchorEnd: anchorEnd
) { ) {
withAnimation { advance(from: index) } withAnimation { advance(from: index) }
} }
@@ -683,6 +716,9 @@ private struct WorkPhaseView: View {
let totalSets: Int let totalSets: Int
let detail: String let detail: String
let isActive: Bool let isActive: Bool
/// When the page was reached by a remote frame, anchor the count-up to the sender's
/// wall clock instead of local `now`, so the mirror lines up regardless of delivery lag.
var anchorStart: Date? = nil
/// Wall-clock anchor for the count-up stopwatch. Driving the display from a fixed /// Wall-clock anchor for the count-up stopwatch. Driving the display from a fixed
/// start date (rendered by SwiftUI's timer text) instead of incrementing a counter /// start date (rendered by SwiftUI's timer text) instead of incrementing a counter
@@ -694,13 +730,15 @@ private struct WorkPhaseView: View {
PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer) { PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer) {
Text(startDate, style: .timer) Text(startDate, style: .timer)
} }
.onAppear { if isActive { restart() } } .onAppear { if isActive { restart(haptic: true) } }
.onChange(of: isActive) { _, active in if active { restart() } } .onChange(of: isActive) { _, active in if active { restart(haptic: true) } }
// A later frame for this same page re-anchors the timer without re-buzzing.
.onChange(of: anchorStart) { _, _ in if isActive { restart(haptic: false) } }
} }
private func restart() { private func restart(haptic: Bool) {
startDate = Date() startDate = anchorStart ?? Date()
WorkoutHaptic.start.play() if haptic { WorkoutHaptic.start.play() }
} }
} }
@@ -720,6 +758,11 @@ private struct CountdownPhaseView: View {
let tint: Color let tint: Color
let seconds: Int let seconds: Int
let isActive: Bool let isActive: Bool
/// When the page was reached by a remote frame, anchor the countdown window to the
/// sender's wall clock (`anchorStart``anchorEnd`) instead of local `now``now+seconds`,
/// so the remaining time and the auto-advance at zero line up across both devices.
var anchorStart: Date? = nil
var anchorEnd: Date? = nil
/// Invoked once the countdown reaches zero (auto-advance to the next page). /// Invoked once the countdown reaches zero (auto-advance to the next page).
let onFinished: () -> Void let onFinished: () -> Void
@@ -736,17 +779,19 @@ private struct CountdownPhaseView: View {
PhaseTimerLayout(header: header, footer: footer, tint: tint) { PhaseTimerLayout(header: header, footer: footer, tint: tint) {
Text(timerInterval: startDate...endDate, countsDown: true) Text(timerInterval: startDate...endDate, countsDown: true)
} }
.onAppear { if isActive { start() } } .onAppear { if isActive { start(haptic: true) } }
.onChange(of: isActive) { _, active in if active { start() } } .onChange(of: isActive) { _, active in if active { start(haptic: true) } }
// A later frame for this same page re-anchors the window without re-buzzing.
.onChange(of: anchorEnd) { _, _ in if isActive { start(haptic: false) } }
.onReceive(ticker) { _ in tick() } .onReceive(ticker) { _ in tick() }
} }
private func start() { private func start(haptic: Bool) {
startDate = Date() startDate = anchorStart ?? Date()
endDate = startDate.addingTimeInterval(Double(max(1, seconds))) endDate = anchorEnd ?? startDate.addingTimeInterval(Double(max(1, seconds)))
lastPingSecond = Int.max lastPingSecond = Int.max
didFinish = false didFinish = false
WorkoutHaptic.start.play() if haptic { WorkoutHaptic.start.play() }
} }
private func tick() { private func tick() {
@@ -777,6 +822,9 @@ private struct FinishPhaseView: View {
let isActive: Bool let isActive: Bool
let onDone: () -> Void let onDone: () -> Void
let onOneMore: () -> Void let onOneMore: () -> Void
/// When the page was reached by a remote frame, anchor the auto-Done deadline to the
/// sender's wall clock instead of local `now + countdown`, so both devices fire together.
var anchorEnd: Date? = nil
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5 @AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
@@ -809,13 +857,13 @@ private struct FinishPhaseView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { if isActive { start() } } .onAppear { if isActive { start() } }
.onChange(of: isActive) { _, active in active ? start() : (didFire = true) } .onChange(of: isActive) { _, active in active ? start() : (didFire = true) }
.onChange(of: anchorEnd) { _, _ in if isActive { start() } }
.onReceive(ticker) { _ in tick() } .onReceive(ticker) { _ in tick() }
} }
private func start() { private func start() {
let total = max(1, doneCountdownSeconds) endDate = anchorEnd ?? Date().addingTimeInterval(Double(max(1, doneCountdownSeconds)))
endDate = Date().addingTimeInterval(Double(total)) remaining = max(0, Int(ceil(endDate.timeIntervalSinceNow)))
remaining = total
didFire = false didFire = false
} }
@@ -68,6 +68,14 @@ struct ExerciseProgressView: View {
/// Highest remote frame version we've applied, so a redelivery doesn't re-jump. /// Highest remote frame version we've applied, so a redelivery doesn't re-jump.
@State private var lastAppliedVersion = 0 @State private var lastAppliedVersion = 0
/// When a remote frame drove us to a page, that page's timer anchors to the *sender's*
/// wall clock (`remoteAnchorStart`/`End`) instead of local `now` so delivery latency
/// can't desync the mirror, the way the old read-only mirror counted off the frame's
/// anchors. Scoped to `remoteAnchorPage`; any local transition to another page self-anchors.
@State private var remoteAnchorPage: Int?
@State private var remoteAnchorStart: Date?
@State private var remoteAnchorEnd: Date?
private enum PageChangeCause { case auto, remote } private enum PageChangeCause { case auto, remote }
/// True when this run opened on a resumed set (in-progress) rather than the Ready /// True when this run opened on a resumed set (in-progress) rather than the Ready
@@ -209,11 +217,13 @@ struct ExerciseProgressView: View {
case .auto: case .auto:
// Rest / timed-work auto-advance: record forward progress, but don't // Rest / timed-work auto-advance: record forward progress, but don't
// broadcast the watch reaches this point on its own synchronized timer. // broadcast the watch reaches this point on its own synchronized timer.
clearRemoteAnchor()
recordProgress(for: newPage) recordProgress(for: newPage)
case .none: case .none:
// A human swipe. Swiping back from the first set to Ready wipes the run // A human swipe. Swiping back from the first set to Ready wipes the run
// (only the adjacent 10 swipe resets a stray far jump never does); any // (only the adjacent 10 swipe resets a stray far jump never does); any
// other page records forward progress. Human transitions are broadcast. // other page records forward progress. Human transitions are broadcast.
clearRemoteAnchor()
if showsReady && newPage == 0 && oldPage == base { if showsReady && newPage == 0 && oldPage == base {
resetExercise() resetExercise()
} else { } else {
@@ -286,11 +296,24 @@ struct ExerciseProgressView: View {
lastAppliedVersion = frame.version lastAppliedVersion = frame.version
if frame.setCount != setCount { setCount = frame.setCount } if frame.setCount != setCount { setCount = frame.setCount }
let target = page(forPhase: frame.phase, setIndex: frame.setIndex) let target = page(forPhase: frame.phase, setIndex: frame.setIndex)
// Anchor the target page's timer to the sender's wall clock, not local now, so the
// displayed countdown matches regardless of how long the frame took to arrive.
remoteAnchorPage = target
remoteAnchorStart = frame.phaseStart
remoteAnchorEnd = frame.phaseEnd
guard target != currentPage else { return } guard target != currentPage else { return }
pageChangeCause = .remote pageChangeCause = .remote
withAnimation { currentPage = target } withAnimation { currentPage = target }
} }
/// Drop the remote anchor when a local transition moves us off the remote-driven page,
/// so the next phase counts off local `now` (and a swipe back doesn't reuse a stale anchor).
private func clearRemoteAnchor() {
remoteAnchorPage = nil
remoteAnchorStart = nil
remoteAnchorEnd = nil
}
/// Inverse of `liveSnapshot`'s pageframe mapping: a frame's phase/set page index. /// Inverse of `liveSnapshot`'s pageframe mapping: a frame's phase/set page index.
private func page(forPhase phase: LiveRunPhase, setIndex: Int) -> Int { private func page(forPhase phase: LiveRunPhase, setIndex: Int) -> Int {
let set = min(max(0, setIndex), max(0, setCount - 1)) let set = min(max(0, setIndex), max(0, setCount - 1))
@@ -344,6 +367,10 @@ struct ExerciseProgressView: View {
@ViewBuilder @ViewBuilder
private func page(for index: Int) -> some View { private func page(for index: Int) -> some View {
let isActive = index == currentPage let isActive = index == currentPage
// Only the remote-driven page carries the sender's anchors; every other page (reached
// locally by swipe or auto-advance) counts off its own `now`.
let anchorStart = index == remoteAnchorPage ? remoteAnchorStart : nil
let anchorEnd = index == remoteAnchorPage ? remoteAnchorEnd : nil
if showsReady && index == 0 { if showsReady && index == 0 {
ReadyPhaseView(summary: readySummary, onStart: start) ReadyPhaseView(summary: readySummary, onStart: start)
} else { } else {
@@ -353,7 +380,8 @@ struct ExerciseProgressView: View {
FinishPhaseView( FinishPhaseView(
isActive: isActive, isActive: isActive,
onDone: { completeExercise(); dismiss() }, onDone: { completeExercise(); dismiss() },
onOneMore: addSet onOneMore: addSet,
anchorEnd: anchorEnd
) )
} else if cycleIndex.isMultiple(of: 2) { } else if cycleIndex.isMultiple(of: 2) {
let setNumber = cycleIndex / 2 + 1 let setNumber = cycleIndex / 2 + 1
@@ -364,7 +392,9 @@ struct ExerciseProgressView: View {
header: "\(setNumber) of \(setCount)", header: "\(setNumber) of \(setCount)",
tint: .workTimer, tint: .workTimer,
seconds: workDurationSeconds, seconds: workDurationSeconds,
isActive: isActive isActive: isActive,
anchorStart: anchorStart,
anchorEnd: anchorEnd
) { ) {
withAnimation { advance(from: index) } withAnimation { advance(from: index) }
} }
@@ -374,7 +404,8 @@ struct ExerciseProgressView: View {
setNumber: setNumber, setNumber: setNumber,
totalSets: setCount, totalSets: setCount,
detail: detail, detail: detail,
isActive: isActive isActive: isActive,
anchorStart: anchorStart
) )
} }
} else { } else {
@@ -383,7 +414,9 @@ struct ExerciseProgressView: View {
header: "Rest", header: "Rest",
tint: .restTimer, tint: .restTimer,
seconds: restSeconds, seconds: restSeconds,
isActive: isActive isActive: isActive,
anchorStart: anchorStart,
anchorEnd: anchorEnd
) { ) {
withAnimation { advance(from: index) } withAnimation { advance(from: index) }
} }
@@ -697,6 +730,9 @@ private struct WorkPhaseView: View {
let totalSets: Int let totalSets: Int
let detail: String let detail: String
let isActive: Bool let isActive: Bool
/// When the page was reached by a remote frame, anchor the count-up to the sender's
/// wall clock instead of local `now`, so the mirror lines up regardless of delivery lag.
var anchorStart: Date? = nil
/// Wall-clock anchor for the count-up stopwatch. Driving the display from a fixed /// Wall-clock anchor for the count-up stopwatch. Driving the display from a fixed
/// start date (rendered by SwiftUI's timer text) instead of incrementing a counter /// start date (rendered by SwiftUI's timer text) instead of incrementing a counter
@@ -707,13 +743,15 @@ private struct WorkPhaseView: View {
PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer) { PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer) {
Text(startDate, style: .timer) Text(startDate, style: .timer)
} }
.onAppear { if isActive { restart() } } .onAppear { if isActive { restart(haptic: true) } }
.onChange(of: isActive) { _, active in if active { restart() } } .onChange(of: isActive) { _, active in if active { restart(haptic: true) } }
// A later frame for this same page re-anchors the timer without re-buzzing.
.onChange(of: anchorStart) { _, _ in if isActive { restart(haptic: false) } }
} }
private func restart() { private func restart(haptic: Bool) {
startDate = Date() startDate = anchorStart ?? Date()
WorkoutHaptic.start.play() if haptic { WorkoutHaptic.start.play() }
} }
} }
@@ -728,6 +766,11 @@ private struct CountdownPhaseView: View {
let tint: Color let tint: Color
let seconds: Int let seconds: Int
let isActive: Bool let isActive: Bool
/// When the page was reached by a remote frame, anchor the countdown window to the
/// sender's wall clock (`anchorStart``anchorEnd`) instead of local `now``now+seconds`,
/// so the remaining time and the auto-advance at zero line up across both devices.
var anchorStart: Date? = nil
var anchorEnd: Date? = nil
/// Invoked once the countdown reaches zero (auto-advance to the next page). /// Invoked once the countdown reaches zero (auto-advance to the next page).
let onFinished: () -> Void let onFinished: () -> Void
@@ -747,17 +790,19 @@ private struct CountdownPhaseView: View {
PhaseTimerLayout(header: header, footer: footer, tint: tint) { PhaseTimerLayout(header: header, footer: footer, tint: tint) {
Text(timerInterval: startDate...endDate, countsDown: true) Text(timerInterval: startDate...endDate, countsDown: true)
} }
.onAppear { if isActive { start() } } .onAppear { if isActive { start(haptic: true) } }
.onChange(of: isActive) { _, active in if active { start() } } .onChange(of: isActive) { _, active in if active { start(haptic: true) } }
// A later frame for this same page re-anchors the window without re-buzzing.
.onChange(of: anchorEnd) { _, _ in if isActive { start(haptic: false) } }
.onReceive(ticker) { _ in tick() } .onReceive(ticker) { _ in tick() }
} }
private func start() { private func start(haptic: Bool) {
startDate = Date() startDate = anchorStart ?? Date()
endDate = startDate.addingTimeInterval(Double(max(1, seconds))) endDate = anchorEnd ?? startDate.addingTimeInterval(Double(max(1, seconds)))
lastPingSecond = Int.max lastPingSecond = Int.max
didFinish = false didFinish = false
WorkoutHaptic.start.play() if haptic { WorkoutHaptic.start.play() }
} }
private func tick() { private func tick() {
@@ -786,6 +831,9 @@ private struct FinishPhaseView: View {
let isActive: Bool let isActive: Bool
let onDone: () -> Void let onDone: () -> Void
let onOneMore: () -> Void let onOneMore: () -> Void
/// When the page was reached by a remote frame, anchor the auto-Done deadline to the
/// sender's wall clock instead of local `now + countdown`, so both devices fire together.
var anchorEnd: Date? = nil
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5 @AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
@@ -817,13 +865,13 @@ private struct FinishPhaseView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { if isActive { start() } } .onAppear { if isActive { start() } }
.onChange(of: isActive) { _, active in active ? start() : (didFire = true) } .onChange(of: isActive) { _, active in active ? start() : (didFire = true) }
.onChange(of: anchorEnd) { _, _ in if isActive { start() } }
.onReceive(ticker) { _ in tick() } .onReceive(ticker) { _ in tick() }
} }
private func start() { private func start() {
let total = max(1, doneCountdownSeconds) endDate = anchorEnd ?? Date().addingTimeInterval(Double(max(1, doneCountdownSeconds)))
endDate = Date().addingTimeInterval(Double(total)) remaining = max(0, Int(ceil(endDate.timeIntervalSinceNow)))
remaining = total
didFire = false didFire = false
} }