From a8d90b638b8d8f06cb451459c0ec6b325d56753b Mon Sep 17 00:00:00 2001 From: rzen Date: Thu, 9 Jul 2026 05:58:32 -0400 Subject: [PATCH] Anchor auto-advanced phases at the boundary, not at tick time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rest/timed-work countdown deadline is shared by both devices, but the page flip crossing it is a local ticker event — and stamping the *next* phase's anchor at Date() when that event finally ran baked a sleeping watch's lateness (throttled wrist-down ticker) into its next count-up, leaving the two devices permanently offset with nothing on the wire to correct. Auto-advances now chain the anchor instead: the finished phase's computed end (passed out of CountdownPhaseView) becomes the next page's PageAnchor, with the next window derived from it via liveSnapshot(for:at:). A device arbitrarily late to a boundary shows exactly what the on-time device shows, and a stack of missed boundaries fast-forwards itself — each chained page lands already-elapsed and advances on its own next tick, skipping the start/stop haptics for boundaries that passed while asleep (only a just-crossed boundary buzzes). The remoteAnchor* fields are generalized into one PageAnchor (remote frames and chained auto-advances are the same concept: a page whose timer counts from a known instant); the phone's Live Activity emit honors it unchanged. --- CHANGELOG.md | 2 + WATCH-SYNC.md | 184 ++++++++++++++++++ .../Views/ExerciseProgressView.swift | 92 +++++---- .../WorkoutLogs/ExerciseProgressView.swift | 98 ++++++---- 4 files changed, 297 insertions(+), 79 deletions(-) create mode 100644 WATCH-SYNC.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b73d10..be504cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ **July 2026** +Watch and iPhone set timers no longer drift apart when the watch sleeps through the end of a rest. + Swiping through sets on the iPhone now reliably moves the watch along too, instead of the two drifting apart mid-workout. On Apple Watch, the workout screen now gives its whole display to the set and rest timer, without the form-guide figure shown on iPhone. diff --git a/WATCH-SYNC.md b/WATCH-SYNC.md new file mode 100644 index 0000000..599f9b1 --- /dev/null +++ b/WATCH-SYNC.md @@ -0,0 +1,184 @@ +# Phone ↔ Watch Communication + +How the iPhone app and the Watch app talk to each other. The governing rule: **the +iPhone is the sole writer of iCloud Drive**. The watch never touches iCloud — it keeps +a local SwiftData cache fed only by phone pushes, edits optimistically, and round-trips +every durable change through the phone. + +> For a per-scenario walkthrough (phone/watch starts a split, drives an exercise, ends a +> run, edit locks, cold launch, …) as sequence + state diagrams — including where the +> watch's HealthKit session leaks — see [`WATCH-SYNC-SCENARIOS.md`](WATCH-SYNC-SCENARIOS.md). + +Source files: + +- Wire format: `Shared/Connectivity/WCPayload.swift` +- Phone side: `Workouts/Connectivity/PhoneConnectivityBridge.swift` +- Watch side: `Workouts Watch App/Connectivity/WatchConnectivityBridge.swift` +- Phone ingest: `SyncEngine.ingestFromWatch` (`Workouts/Sync/SyncEngine.swift`) +- Watch app launch: `Workouts/HealthKit/WorkoutLauncher.swift` + `Workouts Watch App/WorkoutSessionManager.swift` + +## Channels at a glance + +| Channel | Direction | WatchConnectivity API | Semantics | Payload | +|---|---|---|---|---| +| State push | Phone → Watch | `updateApplicationContext` | Latest-wins, delivered even if watch is asleep | All splits + active/recent workouts (JSON `Data` blobs), settings, edit locks | +| Workout update | Watch → Phone | `sendMessage`, falling back to `transferUserInfo` | Immediate when reachable; queued-guaranteed otherwise (unordered mix) | One `WorkoutDocument` | +| Sync request | Watch → Phone | `sendMessage` | Reachable-only, best-effort | Type marker only | +| Live-run mirror | Both ways | `sendMessage` only | Reachable-only + a depth-1 staged re-send on reconnect | `LiveProgress` frame / `liveEnded` marker | +| Watch app launch | Phone → Watch | — (HealthKit, not WC) | Best-effort | `HKWorkoutConfiguration` via `startWatchApp(toHandle:)` | + +```mermaid +flowchart LR + subgraph iPhone + Views[SwiftUI views] --> SE[SyncEngine] + SE -->|writes files| ICD[(iCloud Drive)] + ICD -->|NSMetadataQuery deltas| SE + SE -->|upserts| PC[(SwiftData cache)] + SE -->|onCacheChanged| PB[PhoneConnectivityBridge] + PB --- LRS[LiveRunState] + WL[WorkoutLauncher] + end + + subgraph Watch + WB[WatchConnectivityBridge] + WB -->|upsert + prune| WC[(SwiftData cache)] + WC -->|"@Query"| WViews[Watch views] + WViews -->|"update(workout:)"| WB + WSM[WorkoutSessionManager] + end + + PB -->|"application context: splits + workouts + settings + edit locks"| WB + WB -->|"workoutUpdate / requestSync"| PB + PB <-->|"live-run frames (ephemeral)"| WB + WL -->|"HealthKit startWatchApp"| WSM +``` + +## 1. Phone → Watch: the state push + +`PhoneConnectivityBridge.pushAll()` serializes the phone's cache into one +`updateApplicationContext` dictionary. Application context is **latest-state-wins**: +watchOS keeps only the newest dictionary and delivers it when the watch app runs, so +every push must be complete — a push that omitted a key would read as that state being +cleared. + +**What's in it:** + +- **Splits** — all of them, as `[SplitDocument]` JSON. +- **Workouts** — only what the watch can act on: every active run (in-progress / + not-started, uncapped) plus up to 25 completed ones from the last ~24 h, as + `[WorkoutDocument]` JSON. +- **Settings** — `restSeconds`, `doneCountdownSeconds`, `weightUnit` (from the phone's + `UserDefaults`; the watch writes them into its own). +- **Edit locks** — `editingWorkoutID` / `editingSplitID`: while the phone has a workout + or split open in an editor, the watch parks any matching run and blocks re-entry, so + only one device drives a run at a time. Absent keys mean "not editing". + +**When it fires:** on every `SyncEngine.onCacheChanged` (local edits *and* changes +arriving from iCloud), on session activation, on reachability restored, on a watch +`requestSync`, and immediately on any edit-lock change. + +**How the watch applies it:** `WatchCacheApplier` upserts every document sent, then +**prunes** anything absent — the pushed sets are authoritative, which is how a delete +on the phone propagates (the deleted workout simply stops being sent). A payload that +fails to decode (phone and watch running different document schemas) is skipped +entirely — no upsert, no prune — so a bogus empty set can never wipe real rows. + +## 2. Watch → Phone: the workout round trip + +The watch edits its local cache **optimistically** (so its UI is instant), then +forwards the whole updated `WorkoutDocument` to the phone. Transport is +`sendMessage` when the phone is reachable, with a `transferUserInfo` fallback +(guaranteed, queued, survives the watch app dying) when it isn't or the send fails. +The two are **unordered** relative to each other, which is why the phone gates intake +by `updatedAt`. + +`SyncEngine.ingestFromWatch` on the phone: + +1. **Tombstone veto** — a workout deleted on the phone is never resurrected by a stale + watch copy; the phone just re-pushes authoritative state so the watch drops it. +2. **Pending-delete veto** — same veto for a queued delete whose stub hasn't landed yet. +3. **`updatedAt` intake gate** — strictly newer than the cache is accepted; strictly + older means the watch is behind (re-push state to correct it); equal is a + duplicate/echo (ignored). Note: this arbitrates timestamps, not content — see the + known limit documented at `ingestFromWatch` (durable fix would be per-log merge). +4. An accepted document is written to iCloud Drive and upserted into the cache, which + fires `onCacheChanged` → `pushAll()` — so the watch always gets an authoritative echo. + +```mermaid +sequenceDiagram + participant WV as Watch view + participant WB as WatchConnectivityBridge + participant PB as PhoneConnectivityBridge + participant SE as SyncEngine (phone) + participant IC as iCloud Drive + + WV->>WB: update(workout doc) + WB->>WB: optimistic upsert into watch cache + alt phone reachable + WB->>PB: sendMessage(workoutUpdate) + else unreachable or send failed + WB->>PB: transferUserInfo(workoutUpdate) — queued + end + PB->>SE: ingestFromWatch(doc) + alt tombstoned or pending delete + SE-->>PB: veto — onCacheChanged only + else updatedAt strictly newer + SE->>IC: write Workouts/YYYY/MM/id.json + SE->>SE: upsert phone cache + SE-->>PB: onCacheChanged + end + PB->>WB: updateApplicationContext (authoritative echo) + WB->>WB: upsert + prune watch cache +``` + +A cold-starting watch sends `requestSync` (and re-applies the last received context +eagerly); the phone answers with a fresh `pushAll()`. + +## 3. Live-run mirror (ephemeral, both directions) + +While a run's exercise flow is open on one device, that device broadcasts +`LiveProgress` frames — workout/log IDs, exercise name, phase, set index/count, and a +**wall-clock phase anchor** — so the other device can follow along (the phone's +follower cover, or the watch's, unless that run is already open there or the user +dismissed it). `liveEnded` tells the peer to drop the follower. + +This channel is deliberately *not* persistence: + +- Frames go over `sendMessage` only (reachable-only); they are never written anywhere. +- Each side stages **one** pending frame (latest-wins) and re-sends it when + reachability or activation returns — a newer frame or the terminal `liveEnded` + replaces whatever is staged, so a re-send is never stale. Because frames carry an + absolute wall-clock anchor, a late arrival self-corrects its timers. +- A send that *fails* while the peer is nominally reachable (no reachability edge to + re-flush on) is retried with a short backoff, a handful of times per staged message. +- Receiving a frame that outranks the staged outbound one for the same run **drops the + staged frame** — the peer has moved past it, and a reconnect re-send would yank the + run backwards. Symmetrically, a delivery the staged frame outranks (late or + version-collided) is ignored. +- Both sides stamp frames from a shared monotonic `version` (each bumps its counter + past anything it receives), so either side can drop an out-of-order delivery. The + sequence isn't collision-free — after a lost frame both devices can mint the same + version — so every staleness comparison tie-breaks on the frame's wall-clock anchor + (`LiveProgress.isNewer`): the later human action wins. +- **Durable repair**: if a frame is lost outright (retries spent, no reconnect), the + transition's durable write still lands via its own channel. The run screens compare + the absorbed doc's `currentStateIndex` against everything they've recorded or + followed, and jump *forward* to the first unfinished set's work page — so a lost + frame degrades to a briefly-stale page, never a stuck one. +- **Auto-advances are never sent** — both devices cross countdown boundaries + independently off the shared deadline. Each crossing anchors the *next* phase at the + finished phase's computed end, not at tick time, so a device whose ticker slept + through the boundary (wrist-down throttling) shows the same next-phase timer as one + that crossed on time — and a stack of missed boundaries fast-forwards itself, one + tick per phase, silently (catch-up hops skip the haptics). +- Date anchors ride as native plist values, not JSON — `DocumentCoder` is ISO-8601, + which would round off the sub-second precision the timers need. + +## 4. Launching the Watch app (HealthKit, not WatchConnectivity) + +An iPhone app cannot foreground its Watch app via WatchConnectivity. When a workout +starts on the phone, `WorkoutLauncher` uses the one sanctioned path — HealthKit's +`startWatchApp(toHandle:)` with an `HKWorkoutConfiguration` — which wakes the Watch +app; there, `WorkoutSessionManager` starts a matching `HKWorkoutSession` so the watch +stays foregrounded for the duration of the run. Best-effort: it silently tolerates no +paired watch, and the session is runtime-only — it plays no part in data sync. diff --git a/Workouts Watch App/Views/ExerciseProgressView.swift b/Workouts Watch App/Views/ExerciseProgressView.swift index a15e438..0369aad 100644 --- a/Workouts Watch App/Views/ExerciseProgressView.swift +++ b/Workouts Watch App/Views/ExerciseProgressView.swift @@ -71,13 +71,19 @@ struct ExerciseProgressView: View { /// durable echo of our own progress (or of a frame we followed) never triggers a jump. @State private var knownStateIndex: Int - /// 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? + /// A page reached with a *known* wall-clock anchor counts its timer from that anchor + /// instead of local `now`. Two sources: a remote frame (the sender's clock, so delivery + /// latency can't desync the mirror) and a chained auto-advance (the finished phase's + /// computed end, so a ticker that slept through the boundary — wrist-down throttling — + /// can't bake its lateness into the next phase's timer). Scoped to one page; a human + /// transition to another page self-anchors. + @State private var pageAnchor: PageAnchor? + + private struct PageAnchor { + let page: Int + let start: Date + let end: Date? + } private enum PageChangeCause { case auto, remote } @@ -253,13 +259,13 @@ struct ExerciseProgressView: View { case .auto: // Rest / timed-work auto-advance: record forward progress, but don't // broadcast — the mirror reaches this point on its own synchronized timer. - clearRemoteAnchor() + // The chained anchor for the new page was set by `advance`, not cleared. recordProgress(for: newPage) case .none: // A human swipe. Swiping back from the first set to Ready wipes the run // (only the adjacent 1→0 swipe resets — a stray far jump never does); any // other page records forward progress. Human transitions are broadcast. - clearRemoteAnchor() + clearAnchor() if showsReady && newPage == 0 && oldPage == base { resetExercise() } else { @@ -333,9 +339,7 @@ struct ExerciseProgressView: View { knownStateIndex = frame.phase == .ready ? 0 : max(knownStateIndex, completedSets(for: target)) // 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 + pageAnchor = PageAnchor(page: target, start: frame.phaseStart, end: frame.phaseEnd) guard target != currentPage else { return } pageChangeCause = .remote withAnimation { currentPage = target } @@ -357,17 +361,15 @@ struct ExerciseProgressView: View { if log.sets > setCount { setCount = log.sets } let target = base + min(max(0, completed), setCount - 1) * 2 guard target > currentPage else { return } - clearRemoteAnchor() + clearAnchor() pageChangeCause = .remote 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 + /// Drop the page anchor when a human transition moves us off the anchored page, so the + /// next phase counts off local `now` (and a swipe back doesn't reuse a stale anchor). + private func clearAnchor() { + pageAnchor = nil } /// Inverse of `liveSnapshot`'s page→frame mapping: a frame's phase/set → page index. @@ -384,9 +386,8 @@ struct ExerciseProgressView: View { /// Build the live-run frame for a given page: phase, the set it pertains to, and the /// wall-clock anchors the mirror counts off. Count-down phases (rest, timed work, finish) /// carry an end anchor; a rep-based work set counts up and leaves it `nil`. - private func liveSnapshot(for page: Int) -> LiveProgress? { + private func liveSnapshot(for page: Int, at now: Date = Date()) -> LiveProgress? { guard let log else { return nil } - let now = Date() func frame(_ phase: LiveRunPhase, setIndex: Int, end: Date?) -> LiveProgress { LiveProgress( @@ -433,10 +434,10 @@ struct ExerciseProgressView: View { @ViewBuilder private func page(for index: Int) -> some View { 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 + // Only the anchored page (remote frame or chained auto-advance) carries anchors; + // every other page (reached by a human transition) counts off its own `now`. + let anchorStart = pageAnchor?.page == index ? pageAnchor?.start : nil + let anchorEnd = pageAnchor?.page == index ? pageAnchor?.end : nil if showsReady && index == 0 { ReadyPhaseView(summary: readySummary, onStart: start) } else { @@ -461,8 +462,8 @@ struct ExerciseProgressView: View { isActive: isActive, anchorStart: anchorStart, anchorEnd: anchorEnd - ) { - withAnimation { advance(from: index) } + ) { end in + withAnimation { advance(from: index, phaseEndedAt: end) } } } else { // Rep-based work set — count up; the user swipes left when done. @@ -483,8 +484,8 @@ struct ExerciseProgressView: View { isActive: isActive, anchorStart: anchorStart, anchorEnd: anchorEnd - ) { - withAnimation { advance(from: index) } + ) { end in + withAnimation { advance(from: index, phaseEndedAt: end) } } } } @@ -498,11 +499,20 @@ struct ExerciseProgressView: View { withAnimation { currentPage = base } } - /// Programmatically move one page right (used by the rest auto-advance), guarding - /// against overrun if the user swiped away in the meantime. Tagged `.auto` so the page - /// observer records progress but doesn't broadcast it (the mirror auto-advances too). - private func advance(from index: Int) { + /// Programmatically move one page right when a countdown phase ends, guarding against + /// overrun if the user swiped away in the meantime. Tagged `.auto` so the page observer + /// records progress but doesn't broadcast it (the mirror auto-advances too). + /// + /// `phaseEndedAt` is the finished phase's *computed* end — the next phase began then, + /// not when this tick finally got runtime. Anchoring the next page there means a device + /// that slept through the boundary (throttled wrist-down ticker) shows the same timer + /// as one that crossed it on time — and a stack of missed boundaries fast-forwards + /// itself, since each chained countdown lands already-elapsed and advances on its own + /// next tick. + private func advance(from index: Int, phaseEndedAt end: Date) { guard currentPage == index, index + 1 < totalPages else { return } + pageAnchor = PageAnchor(page: index + 1, start: end, + end: liveSnapshot(for: index + 1, at: end)?.phaseEnd) pageChangeCause = .auto currentPage = index + 1 } @@ -892,8 +902,10 @@ private struct CountdownPhaseView: View { /// 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). - let onFinished: () -> Void + /// Invoked once the countdown reaches zero (auto-advance to the next page), passing the + /// phase's *computed* end so the next phase can anchor at the boundary itself — not at + /// whenever this tick got runtime (a device asleep at the boundary ticks late). + let onFinished: (Date) -> Void @State private var startDate = Date() @State private var endDate = Date() @@ -919,7 +931,9 @@ private struct CountdownPhaseView: View { endDate = anchorEnd ?? startDate.addingTimeInterval(Double(max(1, seconds))) lastPingSecond = Int.max didFinish = false - if haptic { WorkoutHaptic.start.play() } + // No buzz for a chained catch-up page whose whole window already elapsed while + // the device slept — it advances again on its next tick. + if haptic, endDate > Date() { WorkoutHaptic.start.play() } } private func tick() { @@ -931,8 +945,10 @@ private struct CountdownPhaseView: View { // Time's up — final cue and slide on. If the wrist was down the timer may have // stalled; this then fires on the first tick once the app gets runtime again. didFinish = true - WorkoutHaptic.stop.play() - onFinished() + // Buzz only for a boundary that just happened — fast-forwarding through + // boundaries that passed while the device slept stays silent. + if Date().timeIntervalSince(endDate) < 3 { WorkoutHaptic.stop.play() } + onFinished(endDate) } else if remaining <= 3 && remaining < lastPingSecond { // Once-per-second countdown ping for the final three seconds. lastPingSecond = remaining diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index a22df3a..abe259b 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -92,13 +92,19 @@ struct ExerciseProgressView: View { /// durable echo of our own progress (or of a frame we followed) never triggers a jump. @State private var knownStateIndex: Int - /// 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? + /// A page reached with a *known* wall-clock anchor counts its timer from that anchor + /// instead of local `now`. Two sources: a remote frame (the sender's clock, so delivery + /// latency can't desync the mirror) and a chained auto-advance (the finished phase's + /// computed end, so a ticker that slept through the boundary — wrist-down throttling — + /// can't bake its lateness into the next phase's timer). Scoped to one page; a human + /// transition to another page self-anchors. + @State private var pageAnchor: PageAnchor? + + private struct PageAnchor { + let page: Int + let start: Date + let end: Date? + } private enum PageChangeCause { case auto, remote } @@ -294,13 +300,13 @@ struct ExerciseProgressView: View { case .auto: // Rest / timed-work auto-advance: record forward progress, but don't // broadcast — the watch reaches this point on its own synchronized timer. - clearRemoteAnchor() + // The chained anchor for the new page was set by `advance`, not cleared. recordProgress(for: newPage) case .none: // A human swipe. Swiping back from the first set to Ready wipes the run // (only the adjacent 1→0 swipe resets — a stray far jump never does); any // other page records forward progress. Human transitions are broadcast. - clearRemoteAnchor() + clearAnchor() if showsReady && newPage == 0 && oldPage == base { resetExercise() } else { @@ -374,9 +380,9 @@ struct ExerciseProgressView: View { /// reached by a watch frame, so the lock-screen countdown lines up with the mirror. private func emitActivity(for page: Int) { guard var snapshot = liveSnapshot(for: page) else { return } - if page == remoteAnchorPage { - if let start = remoteAnchorStart { snapshot.phaseStart = start } - snapshot.phaseEnd = remoteAnchorEnd + if let anchor = pageAnchor, anchor.page == page { + snapshot.phaseStart = anchor.start + snapshot.phaseEnd = anchor.end } onActivity(snapshot) } @@ -403,9 +409,7 @@ struct ExerciseProgressView: View { knownStateIndex = frame.phase == .ready ? 0 : max(knownStateIndex, completedSets(for: target)) // 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 + pageAnchor = PageAnchor(page: target, start: frame.phaseStart, end: frame.phaseEnd) guard target != currentPage else { return } pageChangeCause = .remote withAnimation { currentPage = target } @@ -427,17 +431,15 @@ struct ExerciseProgressView: View { if log.sets > setCount { setCount = log.sets } let target = base + min(max(0, completed), setCount - 1) * 2 guard target > currentPage else { return } - clearRemoteAnchor() + clearAnchor() pageChangeCause = .remote 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 + /// Drop the page anchor when a human transition moves us off the anchored page, so the + /// next phase counts off local `now` (and a swipe back doesn't reuse a stale anchor). + private func clearAnchor() { + pageAnchor = nil } /// Inverse of `liveSnapshot`'s page→frame mapping: a frame's phase/set → page index. @@ -454,9 +456,8 @@ struct ExerciseProgressView: View { /// Build the live-run frame for a given page: phase, the set it pertains to, and the /// wall-clock anchors the watch counts off. Count-down phases (rest, timed work, finish) /// carry an end anchor; a rep-based work set counts up and leaves it `nil`. - private func liveSnapshot(for page: Int) -> LiveProgress? { + private func liveSnapshot(for page: Int, at now: Date = Date()) -> LiveProgress? { guard let log else { return nil } - let now = Date() func frame(_ phase: LiveRunPhase, setIndex: Int, end: Date?) -> LiveProgress { LiveProgress( @@ -493,10 +494,10 @@ struct ExerciseProgressView: View { @ViewBuilder private func page(for index: Int) -> some View { 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 + // Only the anchored page (remote frame or chained auto-advance) carries anchors; + // every other page (reached by a human transition) counts off its own `now`. + let anchorStart = pageAnchor?.page == index ? pageAnchor?.start : nil + let anchorEnd = pageAnchor?.page == index ? pageAnchor?.end : nil if showsReady && index == 0 { ReadyPhaseView(summary: readySummary, onStart: start) } else { @@ -524,8 +525,8 @@ struct ExerciseProgressView: View { isActive: isActive, anchorStart: anchorStart, anchorEnd: anchorEnd - ) { - withAnimation { advance(from: index) } + ) { end in + withAnimation { advance(from: index, phaseEndedAt: end) } } } else { // Rep-based work set — count up; the user swipes left when done. @@ -547,8 +548,8 @@ struct ExerciseProgressView: View { isActive: isActive, anchorStart: anchorStart, anchorEnd: anchorEnd - ) { - withAnimation { advance(from: index) } + ) { end in + withAnimation { advance(from: index, phaseEndedAt: end) } } adjustPill } @@ -616,11 +617,20 @@ struct ExerciseProgressView: View { withAnimation { currentPage = base } } - /// Programmatically move one page right (used by the rest auto-advance), guarding - /// against overrun if the user swiped away in the meantime. Tagged `.auto` so the page - /// observer records progress but doesn't broadcast it (the watch auto-advances too). - private func advance(from index: Int) { + /// Programmatically move one page right when a countdown phase ends, guarding against + /// overrun if the user swiped away in the meantime. Tagged `.auto` so the page observer + /// records progress but doesn't broadcast it (the watch auto-advances too). + /// + /// `phaseEndedAt` is the finished phase's *computed* end — the next phase began then, + /// not when this tick finally got runtime. Anchoring the next page there means a device + /// that slept through the boundary (throttled wrist-down ticker) shows the same timer + /// as one that crossed it on time — and a stack of missed boundaries fast-forwards + /// itself, since each chained countdown lands already-elapsed and advances on its own + /// next tick. + private func advance(from index: Int, phaseEndedAt end: Date) { guard currentPage == index, index + 1 < totalPages else { return } + pageAnchor = PageAnchor(page: index + 1, start: end, + end: liveSnapshot(for: index + 1, at: end)?.phaseEnd) pageChangeCause = .auto currentPage = index + 1 } @@ -1150,8 +1160,10 @@ private struct CountdownPhaseView: View { /// 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). - let onFinished: () -> Void + /// Invoked once the countdown reaches zero (auto-advance to the next page), passing the + /// phase's *computed* end so the next phase can anchor at the boundary itself — not at + /// whenever this tick got runtime (a device asleep at the boundary ticks late). + let onFinished: (Date) -> Void /// Wall-clock window for the countdown. SwiftUI renders the remaining time from this /// range, and the haptics + auto-advance below are derived from `endDate` rather than @@ -1181,7 +1193,9 @@ private struct CountdownPhaseView: View { endDate = anchorEnd ?? startDate.addingTimeInterval(Double(max(1, seconds))) lastPingSecond = Int.max didFinish = false - if haptic { WorkoutHaptic.start.play() } + // No buzz for a chained catch-up page whose whole window already elapsed while + // the device slept — it advances again on its next tick. + if haptic, endDate > Date() { WorkoutHaptic.start.play() } } private func tick() { @@ -1191,8 +1205,10 @@ private struct CountdownPhaseView: View { if remaining <= 0 { didFinish = true - WorkoutHaptic.stop.play() - onFinished() + // Buzz only for a boundary that just happened — fast-forwarding through + // boundaries that passed while the device slept stays silent. + if Date().timeIntervalSince(endDate) < 3 { WorkoutHaptic.stop.play() } + onFinished(endDate) } else if remaining <= 3 && remaining < lastPingSecond { // Once-per-second countdown ping for the final three seconds. lastPingSecond = remaining