Hopp til innhold

Slik holder Musklr Apple Watch og iPhone i synk gjennom hele økta

En Musklr-utviklingslogg om å holde Apple Watch og iPhone i synk under en økt.

Slik ser det ut å logge et sett når telefonen blir liggende i lomma. Still inn vekta med kronen, trykk én gang for å logge, fortsett å løfte. For at det skal føles umiddelbart, må klokka og telefonen være enige om sannheten hele tiden. Her er hvordan vi fikk dem til å bli enige, og de fem synkfeilene vi måtte ta knekken på først.

Logging av en beinpress fra håndleddet i Musklr, uten telefon i hånda.

Slik fungerer synkingen egentlig

Designet er bevisst ensidig. iPhone eier hver bit av økt-tilstanden. Den bygger en WorkoutSnapshot, én struct som bærer hele visningstilstanden for økta, og det øyeblikksbildet er den eneste kilden til sannhet. Apple Watch er en skjerm som bare leser. Den viser det siste øyeblikksbildet og ingenting annet. Når du vrir på kronen eller trykker på en knapp på håndleddet, endrer ikke klokka noen tilstand selv. Den sender en liten WatchCommand tilbake til telefonen og venter på at telefonen skal sende et nytt øyeblikksbilde.

Ledningen mellom dem er WCSession. Telefonen sender hvert øyeblikksbilde over updateApplicationContext, som systemet holder på og leverer selv om klokka sover eller er utenfor rekkevidde, og beholder alltid bare den siste verdien. Klokka sender kommandoene sine tilbake over sendMessage. To typer, én ledning, én eier av sannheten. Nesten hver feil under her er en variant av samme tabbe: at den ene siden stoler på en kopi av sannheten som allerede var utdatert.

Slik holder Musklr iPhone og Apple Watch i synk På iPhone bygger WorkoutSessionManager en WorkoutSnapshot, den eneste kilden til sannhet for økta. WCSession sender den til klokka over to transporter samtidig: den garanterte, sammenslåtte updateApplicationContext, og en raskere sendMessage-vei som bare brukes mens klokka er innen rekkevidde. Klokka er en skjerm som bare leser. Den sender små WatchCommand-er tilbake til iPhone over sendMessage. IPHONE WorkoutSessionManager bygger WorkoutSnapshot eneste kilde til sannhet WCSESSION updateApplicationContext garantert · slås sammen av OS · siste verdi vinner sendMessage hurtigvei · bare når klokka er innen rekkevidde nær umiddelbar WatchCommand via sendMessage · køes hvis utenfor rekkevidde APPLE WATCH PhoneConnectivityManager mottar WatchSessionViewModel skjerm som bare leser
iPhone eier all tilstand. WorkoutSessionManager bygger WorkoutSnapshot og sender den til klokka over to transporter samtidig: den garanterte, sammenslåtte updateApplicationContext, og en raskere sendMessage-vei som bare brukes mens klokka er innen rekkevidde. Klokka skriver aldri sin egen kopi; den sender bare WatchCommander tilbake.

Den ene kilden til sannhet er det som gjør at håndleddet føles umiddelbart. Musklr er gratis i App Store, to trykk for å logge et sett.

Last ned Musklr gratis Se alt Musklr kan

Fem synkfeil vi fant og tok knekken på

Ingen av disse nådde noen brukere. Vi fanget dem mens vi bygde håndledds-opplevelsen, og hver eneste viste seg å ha samme form da vi så etter: klokka og telefonen var et kort øyeblikk uenige om hva som var sant. Her er de, symptom, årsak og løsning.

Feil 1: Vekta som spratt tilbake til null

Symptom. Du ruller kronen til 100 kg på håndleddet. Den viser 100. Ett til to sekunder senere, uten at hånda er i nærheten av klokka, hopper den tilbake til null.

Årsak. Hvert 15. sekund sender telefonen på nytt det siste øyeblikksbildet som en puls, slik at en klokke som gikk glipp av en oppdatering likevel kommer i takt. Problemet var at det bufrede øyeblikksbildet fortsatt bar verdien fra før endringen din. Da pulsen kom fram, behandlet klokka den som fersk sannhet og nullstilte den optimistiske verdien du nettopp hadde stilt inn.

Løsning. To halvdeler. På telefonen, når en krone-endring kommer inn, lapper vi det bufrede lastPushedSnapshot på stedet, slik at neste puls bærer den nåværende verdien din i stedet for den gamle. På klokka nullstilles en optimistisk verdi bare når telefonen bekrefter den (et øyeblikksbilde for samme sett som bærer den verdien), eller når endringen slutter å være relevant (settet endret seg, eller du forlot aktivt sett-skjermen). En puls kan ikke lenger viske ut en endring telefonen ikke har bekreftet.

Her er avstemmingen på klokke-siden, sammen med lappen på telefon-siden som holder pulsen ærlig:

// Source:
//   MusklrWatch Watch App/Services/WatchSessionViewModel.swift
//   Musklr/Services/Workout/WorkoutSnapshotPublisher.swift
//
// Beat: the Watch clears an optimistic crown-scroll value ONLY when the
// phone CONFIRMS it (a snapshot for the same set carrying that value) — or
// the edit becomes irrelevant (the set changed, or we left active-set) —
// never on a stale 15s heartbeat re-push. The phone-side companion keeps its
// own cached `lastPushedSnapshot` patched with in-flight edits so that same
// heartbeat re-pushes the CURRENT value instead of the pre-edit one.
// (commits 5fad1d8b, dd3c1b57)

import Foundation

// stub: trimmed WorkoutPhase — the real enum (MusklrShared/Models/WorkoutPhase.swift)
// has more cases (rest, betweenExercises, timedExercise, prCelebration, ...).
enum WorkoutPhase: String, Codable, Equatable {
    case activeSet, setComplete
}

// stub: trimmed WorkoutSnapshot — the real struct (MusklrShared/Models/WorkoutSnapshot.swift)
// carries dozens of fields; only the ones `reconcile` and `updateLastPushedValue` read are kept.
struct WorkoutSnapshot: Codable, Hashable {
    var phase: WorkoutPhase
    var value1: Double?
    var value2: Double?
    var currentSetId: String?
}

// MARK: - Watch side (WatchSessionViewModel.swift)

@MainActor
final class WatchSessionViewModel {

    private(set) var snapshot: WorkoutSnapshot

    init(snapshot: WorkoutSnapshot) {
        self.snapshot = snapshot
    }

    /// Local accumulator for the focused column while the user is scrolling.
    private var optimisticValue1: Double?
    private var optimisticValue2: Double?

    /// The value + set id last flushed to the phone, per column. An optimistic
    /// value is cleared only once the phone CONFIRMS it (a snapshot for the
    /// same set carrying that value) or the edit becomes irrelevant (the
    /// displayed set changed, or we left active-set). Replaces the old "clear
    /// whenever not mid-scroll" rule, which let a stale 15s heartbeat re-push
    /// wipe an unconfirmed edit.
    private var flushedValue1: Double?
    private var flushedValue2: Double?
    private var flushedSetId1: String?
    private var flushedSetId2: String?

    /// Debounce task — fires the actual send after the crown has been idle for 250ms.
    private var sendDebounceTask: Task<Void, Never>?

    /// Drop optimistic values only when the phone has confirmed them or the
    /// edit is no longer relevant — never on a stale heartbeat re-push.
    private func reconcileOptimisticValues(against snapshot: WorkoutSnapshot) {
        // Never disturb an edit the user is still actively scrolling.
        guard sendDebounceTask == nil else { return }
        reconcile(column1: true, against: snapshot)
        reconcile(column1: false, against: snapshot)
    }

    private func reconcile(column1: Bool, against snapshot: WorkoutSnapshot) {
        let optimistic = column1 ? optimisticValue1 : optimisticValue2
        guard optimistic != nil else { return }
        let flushedSetId = column1 ? flushedSetId1 : flushedSetId2
        let flushedValue = column1 ? flushedValue1 : flushedValue2
        let snapshotValue = column1 ? snapshot.value1 : snapshot.value2

        let leftActiveSet = snapshot.phase != .activeSet && snapshot.phase != .setComplete
        let setChanged = flushedSetId != nil && snapshot.currentSetId != flushedSetId
        let confirmed: Bool = {
            guard let flushedValue, let snapshotValue else { return false }
            return abs(snapshotValue - flushedValue) < 0.001
        }()

        guard leftActiveSet || setChanged || confirmed else { return }
        if column1 {
            optimisticValue1 = nil; flushedValue1 = nil; flushedSetId1 = nil
        } else {
            optimisticValue2 = nil; flushedValue2 = nil; flushedSetId2 = nil
        }
    }
}

// MARK: - Phone side (WorkoutSnapshotPublisher.swift)

@MainActor
final class WorkoutSnapshotPublisher {

    private(set) var lastPushedSnapshot: WorkoutSnapshot?

    /// Keep the cached `lastPushedSnapshot` consistent with a Watch value
    /// edit so the 15s heartbeat re-push (`rePushLast`) carries the CURRENT
    /// value instead of the stale one it was built with. Without this, the
    /// heartbeat re-pushes the pre-edit value; the Watch clears its optimistic
    /// value on arrival and reverts the user's edit ("weight snaps back to
    /// 0/55"). Emits NO push — it only mends the cached snapshot for the next
    /// heartbeat. Skips the patch when the cached snapshot has advanced past
    /// the edited set, so a value for a non-displayed set never smears on.
    func updateLastPushedValue(column1: Bool, value: Double, targetSetId: String?) {
        guard var snapshot = lastPushedSnapshot else { return }
        if let targetSetId, let current = snapshot.currentSetId, current != targetSetId {
            return
        }
        if column1 { snapshot.value1 = value } else { snapshot.value2 = value }
        lastPushedSnapshot = snapshot
    }
}
Musklrs aktivt sett-skjerm på Apple Watch under en beinpress, som viser 100 kg og 12 repetisjoner med en Sett gjort-knapp.
Aktivt sett-skjermen på håndleddet: to kolonner, kronen for å stille inn hver av dem, ett trykk for å logge. Feil 1 bodde nettopp her, i venstre kolonne som spratt tilbake til null midt i endringen.

Feil 2: Spøkelses-settet «sett 4 av 3»

Symptom. Fullfør det siste settet i en øvelse med tre sett, og klokka blinker «Sett 4 av 3», et sett som ikke finnes.

Årsak. Én funksjon, currentExerciseForActivity(), avgjør hvilken øvelse Watch-kortet beskriver. Den kunne uten videre returnere en øvelse der alle settene var fullført, så kortet talte ett forbi slutten.

Løsning. En øvelse teller nå bare som genuint i gang når den har både et fullført sett og et uferdig sett. En ferdig øvelse faller gjennom til neste kandidat, så kortet kan aldri peke ett forbi slutten.

Den bærende regelen er det midterste søket, som krever både et fullført og et uferdig sett før en øvelse kan vinne:

// Source: Musklr/Services/Workout/WorkoutSessionManager.swift — currentExerciseForActivity()
//
// Beat: which exercise counts as "in progress" for the Watch / Live Activity
// card. A fully-completed exercise must NOT win the picker — it would render
// a phantom "set N+1 of N" and out-rank a freshly-added exercise that has no
// completed sets yet. The middle tier is the load-bearing rule: an exercise
// only qualifies as "genuinely in progress" when it has BOTH at least one
// completed set AND at least one incomplete set.
// (commit 381748e0)

import Foundation

// stub: trimmed Exercise — the real struct (Musklr/Models/Exercise/Exercise.swift)
// is a GRDB record with dozens of catalog fields; only `id` matters here.
struct Exercise: Identifiable, Equatable {
    let id: Int
}

// stub: trimmed WorkoutSet — the real struct (Musklr/Models/Workout/WorkoutSet.swift)
// carries weight/reps/duration/target fields; only `isCompleted` matters here.
struct WorkoutSet: Identifiable, Equatable {
    let id: String
    var isCompleted: Bool
}

final class WorkoutSessionManager {

    /// User-pinned "current" exercise (e.g. tapped from the workout list).
    var anchorExerciseId: Int?

    /// Session exercises in display order, each with its resolved sets.
    /// Real property is `[(exerciseId: Int, exercise: Exercise, sets: [WorkoutSet])]`.
    var sessionExercises: [(exerciseId: Int, exercise: Exercise, sets: [WorkoutSet])] = []

    /// Resolve which exercise the Watch / Live Activity card should describe
    /// right now.
    func currentExerciseForActivity() -> Exercise? {
        // Anchor wins when set, resolvable, AND still has incomplete sets.
        // If the anchored exercise is fully complete we fall through to scan
        // order so a different incomplete exercise can become "current".
        if let anchor = anchorExerciseId,
           let group = sessionExercises.first(where: { $0.exerciseId == anchor }),
           group.sets.contains(where: { !$0.isCompleted }) {
            return group.exercise
        }
        // Find the last exercise that is genuinely IN PROGRESS — has at least
        // one completed set AND at least one incomplete set. A fully-completed
        // exercise must not win here: it would render a phantom "set N+1 of N"
        // on the Watch and out-rank a freshly-added incomplete exercise that has
        // no completed sets yet. Fully-complete exercises fall through to the
        // first-incomplete scan below.
        for group in sessionExercises.reversed() {
            if group.sets.contains(where: { $0.isCompleted }),
               group.sets.contains(where: { !$0.isCompleted }) {
                return group.exercise
            }
        }
        // Fallback: first exercise with incomplete sets
        for group in sessionExercises {
            if group.sets.contains(where: { !$0.isCompleted }) {
                return group.exercise
            }
        }
        return sessionExercises.first?.exercise
    }
}

Feil 3: «Økt fullført» når du ikke var det, og den svarte boksen

Symptom. Fullfør det siste settet du hadde i kø, og klokka slår om til «økt fullført», mens Live Activity på låseskjermen blir svart. Du var ikke ferdig. Du skulle akkurat legge til en øvelse til.

Årsak. Da pausetimeren løp ut og ingenting sto igjen uferdig, sendte advanceToNextPhase() .workoutComplete med en gang. Men økta er fortsatt åpen på det tidspunktet. På klokka leste det som ferdig, og Live Activity, som fikk en fullført økt uten innhold å tegne, viste en tom visning: en svart boks på låseskjermen din.

Løsning. Når hvert nåværende sett er gjort, men økta fortsatt er åpen, ruter vi til en ventetilstand mellom øvelser («Alle sett gjort. Legg til en øvelse på telefonen, eller avslutt.») i stedet for å fullføre. Bare den eksplisitte Avslutt-knappen når .workoutComplete.

Når hvert nåværende sett er gjort, men økta fortsatt er åpen, sender vi ventetilstanden i stedet for å fullføre økta:

// Source: Musklr/Services/Workout/WorkoutSnapshotPublisher.swift — advanceToNextPhase()
//
// Beat: when the rest timer expires (or a no-rest set completes) and every
// current set is done, the session is still OPEN — the user may add another
// exercise. Auto-advancing straight to `.workoutComplete` here made the Watch
// show "workout done" and the Live Activity render an EmptyView (a black box)
// while the session was still live. Route to the between-exercises WAITING
// state instead; only the explicit Finish button reaches `.workoutComplete`.
// (commits 98eba51c, 2afd8387)

import Foundation

// stub: trimmed WorkoutPhase — the real enum has more cases.
enum WorkoutPhase: String, Equatable {
    case activeSet, betweenExercises, workoutComplete
}

// stub: trimmed WorkoutSnapshot — only what this beat needs to build/carry.
struct WorkoutSnapshot {
    var phase: WorkoutPhase
}

// stub: trimmed WorkoutSet — only `exerciseId`/`isCompleted` matter here.
struct WorkoutSet {
    let exerciseId: Int
    var isCompleted: Bool
}

// stub: trimmed WorkoutSessionManager surface this beat reads.
final class WorkoutSessionManager {
    var sessionSets: [WorkoutSet] = []

    /// Most recently completed set, or nil if nothing has been completed yet.
    func lastCompletedSet() -> WorkoutSet? {
        sessionSets.last(where: \.isCompleted)
    }
}

final class WorkoutSnapshotPublisher {
    private unowned let manager: WorkoutSessionManager

    init(manager: WorkoutSessionManager) { self.manager = manager }

    // stub: real implementations build + push a full WorkoutSnapshot to
    // Watch + Live Activity; only the phase-selection logic below is the point.
    private func pushToAllSurfaces(_ snapshot: WorkoutSnapshot) {}
    private func pushPhase(_ phase: WorkoutPhase) {}
    private func buildBetweenExercisesSnapshot() -> WorkoutSnapshot {
        WorkoutSnapshot(phase: .betweenExercises)
    }

    /// Called when the rest timer expires. Chooses the appropriate next phase
    /// and pushes a fresh snapshot. Replaces the previous "do nothing" behavior
    /// that left the Watch stuck on "0:00 · Skip rest".
    ///
    /// Phase selection:
    /// - `.activeSet` — the exercise of the most-recently-completed set still
    ///   has incomplete sets
    /// - `.betweenExercises` — the current exercise is finished, whether or
    ///   not other exercises still have incomplete sets. `.workoutComplete`
    ///   is never pushed from here — the session is still open and the user
    ///   may add another exercise; only the explicit Finish button
    ///   (`completeSession`) reaches `.workoutComplete`.
    func advanceToNextPhase() {
        let remainingIncomplete = manager.sessionSets.filter { !$0.isCompleted }
        if remainingIncomplete.isEmpty {
            // Every current set is done, but the SESSION is still open — the
            // user may add another exercise. Do NOT auto-complete: that made
            // the Watch show "workout done" and the Live Activity render
            // EmptyView (a black box) while the session was still live. Show
            // the between-exercises waiting state instead; the explicit Finish
            // button (`completeSession`) is the only path to `.workoutComplete`.
            pushToAllSurfaces(buildBetweenExercisesSnapshot())
            return
        }

        let lastCompletedExerciseId = manager.lastCompletedSet()?.exerciseId
        let hasMoreInSameExercise: Bool = {
            guard let id = lastCompletedExerciseId else { return false }
            return manager.sessionSets.contains { $0.exerciseId == id && !$0.isCompleted }
        }()

        if hasMoreInSameExercise {
            pushPhase(.activeSet)
        } else {
            pushToAllSurfaces(buildBetweenExercisesSnapshot())
        }
    }
}

Hver eneste av disse rettelsene er i Musklr i dag. Logg ditt neste sett fra håndleddet, gratis.

Last ned Musklr gratis Se alt Musklr kan

Feil 4: Legg til en øvelse, og klokka overser den

Symptom. Du fullfører alt, og legger så til en ny øvelse på telefonen. Klokka fortsetter å vise den gamle, ferdige øvelsen og nevner aldri det nye settet.

Årsak. Den samme currentExerciseForActivity() fra feil 2. Den nettopp fullførte øvelsen, med alle sett ferdige, vant fortsatt plukkeren og rangerte over den nye øvelsen, som ennå ikke hadde noen fullførte sett.

Løsning. Den samme i gang-regelen løser det gratis. En helt ferdig øvelse kan ikke lenger vinne, så den nye uferdige øvelsen er den klokka plukker opp. To feil, ett predikat.

Dette er det samme predikatet fra feil 2, som gjør dobbel jobb.

Feil 5: Ventingen etter hver handling på håndleddet

Symptom. Hopp over en pause eller stopp en timer på håndleddet, og skjermen bare blir stående, i opptil flere sekunder, før den tar igjen.

Årsak. Øyeblikksbilder gikk ut bare over updateApplicationContext. Den transporten er den rette for garantert levering i bakgrunnen, men systemet slår den sammen og kan holde på den i flere sekunder. Det er usynlig for en bakgrunnsoppdatering og altfor tregt for en overgang du utløste for et øyeblikk siden.

Løsning. To deler. Klokka slår om sin egen fase umiddelbart ved en utvetydig handling, så grensesnittet aldri venter på ledningen (optimistisk UI). Og når klokka er innen rekkevidde, sender telefonen nå det identiske øyeblikksbildet over sendMessage også, en nær umiddelbar hurtigvei, mens updateApplicationContext forblir den garanterte reserven. Begge bærer samme innhold, så klokka beholder rett og slett den som kommer først og ignorerer duplikatet.

Telefonen sender over begge transportene samtidig, den garanterte og den raske:

// Source: Musklr/Services/Watch/WatchConnectivityManager.swift — push(_:)
//
// Beat: every snapshot goes out over `updateApplicationContext` — OS-coalesced,
// but GUARANTEED delivery even if the Watch is unreachable right now. When the
// Watch IS currently reachable, the same payload is ALSO sent via `sendMessage`
// as a near-immediate fast-path accelerator, because `updateApplicationContext`
// can lag several seconds. The Watch dedups the later application-context copy
// against the same payload, so the dual send is safe, not a double-apply.
// (commits 2044b158, 518507be)

import Foundation
import WatchConnectivity

// stub: trimmed WorkoutSnapshot — only the wire encoding matters here.
struct WorkoutSnapshot {
    var phase: String

    func toDictionary() -> [String: Any] {
        ["phase": phase]
    }
}

// stub: real gate (Musklr/Services/Watch/WatchPushGate.swift) also checks
// activation state and the user's sync preference; collapsed to a bool here.
enum WatchPushGate {
    static func shouldPush(
        isSupported: Bool,
        activationState: WCSessionActivationState,
        isWatchAppInstalled: Bool,
        syncEnabled: Bool
    ) -> Bool {
        isSupported && isWatchAppInstalled && syncEnabled && activationState == .activated
    }
}

// stub: real preference (Musklr/Services/Watch/WatchSyncPreference.swift) is
// backed by UserDefaults; collapsed to a constant here.
enum WatchSyncPreference {
    static let isEnabled = true
}

// stub: minimal logging shim standing in for the app's Sentry-bound Logger.
enum Logger {
    static func log(_ message: String, category: String, type: String = "default") {}
    static func debug(_ message: String, category: String) {}
    static func error(_ message: String, category: String) {}
}

final class WatchConnectivityManager: NSObject {

    /// Push a WorkoutSnapshot to the Watch via application context.
    /// Uses updateApplicationContext so the Watch always has the latest state,
    /// even if it's not currently reachable.
    func push(_ snapshot: WorkoutSnapshot) {
        let supported = WCSession.isSupported()
        guard WatchPushGate.shouldPush(
            isSupported: supported,
            activationState: WCSession.default.activationState,
            isWatchAppInstalled: supported && WCSession.default.isWatchAppInstalled,
            syncEnabled: WatchSyncPreference.isEnabled
        ) else {
            // Either sync disabled by the user, not activated, or no
            // counterpart Watch app installed. Skip silently (debug) instead
            // of letting updateApplicationContext throw
            // WCErrorCodeWatchAppNotInstalled (7006) on every push + the 15s
            // heartbeat, which floods the log and Sentry.
            Logger.debug(
                "Skipping snapshot push — gate closed (syncEnabled=\(WatchSyncPreference.isEnabled), activated=\(WCSession.default.activationState == .activated), watchInstalled=\(WCSession.default.isWatchAppInstalled))",
                category: "watchConnectivity"
            )
            return
        }
        let context = snapshot.toDictionary()
        guard !context.isEmpty else { return }
        do {
            try WCSession.default.updateApplicationContext(context)
            // Fast-path: updateApplicationContext is OS-coalesced and can lag
            // several seconds. When the Watch is reachable, ALSO deliver via
            // sendMessage (near-immediate) so command-triggered transitions
            // (stop/start timer, skip/adjust rest) reflect without the delay.
            // Same payload → the Watch dedups the later application-context copy.
            if WCSession.default.isReachable {
                WCSession.default.sendMessage(context, replyHandler: nil) { error in
                    // Transient + self-healing: the Watch can go unreachable in
                    // the window between the isReachable check and delivery
                    // (documented WCSession race — e.g. the Watch app
                    // backgrounds mid-workout), so sendMessage fails with a
                    // WCError. The updateApplicationContext copy above already
                    // succeeded and guarantees delivery, so this accelerator
                    // failing is benign — log at .info, NOT Logger.error (which
                    // is Sentry-bound and would flood noise on every flap).
                    Logger.log(
                        "Fast-path snapshot sendMessage failed (benign — application context still delivers): \(error)",
                        category: "watchConnectivity",
                        type: "info"
                    )
                }
            }
            Logger.log(
                "Pushed snapshot to Watch — phase=\(snapshot.phase) reachable=\(WCSession.default.isReachable)",
                category: "watchConnectivity"
            )
        } catch {
            Logger.error("Failed to push snapshot: \(error)", category: "watchConnectivity")
        }
    }
}

Og klokka venter ikke på at noen av dem skal komme fram når neste fase er utvetydig:

// Source: MusklrWatch Watch App/Services/WatchSessionViewModel.swift
//   — skipRest(), stopTimedExercise(), applyOptimisticPhase(_:)
//
// Beat: the Watch flips its LOCAL phase immediately on a user action, before
// the phone round-trip completes, so the UI never waits on WatchConnectivity
// latency for an unambiguous transition. The next phone snapshot (whichever
// arrives first — the `sendMessage` fast-path or the held
// `updateApplicationContext`) overwrites it wholesale via latest-wins.
// `skipRest` only takes this shortcut when the target phase is UNAMBIGUOUS:
// a rest WITHIN an exercise always returns to `.activeSet`, but a rest that
// was the exercise's LAST set advances to `.betweenExercises` — a phase whose
// content (the picker list) this rest snapshot doesn't carry, so guessing it
// locally would risk a rest→activeSet→between double transition or an empty
// picker. That case is left to the phone's fast-path reply instead.
// (commits 2044b158, 518507be)

import Foundation

// stub: trimmed WorkoutPhase — the real enum has more cases.
enum WorkoutPhase: Equatable {
    case activeSet, rest, betweenExercises
}

// stub: trimmed WorkoutSnapshot — only the fields this beat reads/writes.
struct WorkoutSnapshot {
    var phase: WorkoutPhase
    /// Rest phase only: true when SKIPPING this rest advances to
    /// `.betweenExercises` (the just-completed set was its exercise's last).
    var restLeadsToBetweenExercises: Bool?
}

// stub: trimmed WatchCommand — the real enum carries payloads for every
// Watch → phone action (updateValue1/2, completeSet, adjustRest, ...).
enum WatchCommand {
    case skipRest
    case stopTimedExercise
}

// stub: trimmed transport protocol — the real `PhoneConnectivityManaging`
// also exposes `snapshot`/`snapshotPublisher`/`isReachablePublisher`.
protocol PhoneConnectivityManaging {
    func send(_ command: WatchCommand)
}

@MainActor
final class WatchSessionViewModel: ObservableObject {

    @Published var snapshot: WorkoutSnapshot
    let connectivity: any PhoneConnectivityManaging

    init(snapshot: WorkoutSnapshot, connectivity: any PhoneConnectivityManaging) {
        self.snapshot = snapshot
        self.connectivity = connectivity
    }

    func skipRest() {
        connectivity.send(.skipRest)
        // Only optimistically flip when the next phase is unambiguous — a rest
        // WITHIN an exercise returns to .activeSet. When the just-completed set
        // was the exercise's last, the phone advances to .betweenExercises, whose
        // content (allIncompleteExercises) this rest snapshot doesn't carry, so a
        // local flip would cause a rest→activeSet→between double transition (or an
        // empty picker). Leave the rest view up; the phone's fast-path reply
        // delivers the correct next phase with its content in ~sub-second.
        if snapshot.restLeadsToBetweenExercises != true {
            applyOptimisticPhase(.activeSet)
        }
    }

    func stopTimedExercise() {
        connectivity.send(.stopTimedExercise)
        applyOptimisticPhase(.activeSet)
    }

    /// Flip the local phase now; the next phone snapshot (fast-path or
    /// application context) overwrites it wholesale via the sink's latest-wins.
    private func applyOptimisticPhase(_ phase: WorkoutPhase) {
        var optimistic = snapshot
        optimistic.phase = phase
        snapshot = optimistic
    }
}

Å lese om synk er én ting. Kjenn det selv. Musklr logger et sett med to trykk og virker offline.

Last ned Musklr gratis Se alt Musklr kan

Løsningen som lagde en ny feil

Det optimistiske omslaget fra feil 5 var litt for ivrig. Å hoppe over en pause fører deg normalt tilbake til det aktive settet, så klokka slo rett om til .activeSet. Men når pausen du hoppet over kom rett etter det siste settet i en øvelse, går telefonen videre til .betweenExercises i stedet. Så klokka slo om til .activeSet, og så kom telefonens virkelige øyeblikksbilde og slo den om igjen til .betweenExercises: en dobbel overtoning fra pause til aktiv til mellom, eller et glimt av en tom plukker, siden pause-øyeblikksbildet ikke bærer lista over neste øvelse.

Men telefonen kjenner allerede svaret. I det øyeblikket den bygger pause-øyeblikksbildet, vet den om det å hoppe over vil lande på mellom øvelser, så den stempler det på restLeadsToBetweenExercises. Klokka leser hintet og tar bare den optimistiske snarveien når målet er utvetydig. Når det ikke er det, lar klokka pauseskjermen bli stående og venter på telefonens hurtigvei-svar, som nå kommer på godt under et sekund. Forutsi bare det du kan forutsi riktig.

Telefonen stempler forutsigelsen på pause-øyeblikksbildet, og klokka leser den før den bestemmer seg for om den skal slå om:

// Source: Musklr/Services/Workout/WorkoutSnapshotPublisher.swift — buildRestSnapshot(...)
//
// Beat (bonus): the phone computes, at the moment it builds the rest
// snapshot, whether SKIPPING this rest will land on `.betweenExercises`
// (the just-completed set was its exercise's last) or `.activeSet` (the
// exercise still has incomplete sets) — and stamps that prediction onto
// `restLeadsToBetweenExercises`. The Watch reads this hint in `skipRest()`
// (see optimistic-phase.swift) to decide whether an optimistic local phase
// flip is safe, avoiding a rest→activeSet→between double transition.
// (commit c531cd56)

import Foundation

// stub: trimmed WorkoutPhase — the real enum has more cases.
enum WorkoutPhase: Equatable {
    case rest
}

// stub: trimmed Exercise — only the display name matters here.
struct Exercise: Equatable {
    let id: Int
    let localizedName: String
}

// stub: trimmed WorkoutSet — only what next-exercise resolution needs.
struct WorkoutSet {
    let exerciseId: Int
}

// stub: trimmed WorkoutSnapshot — only the rest-phase fields this beat sets.
struct WorkoutSnapshot {
    var phase: WorkoutPhase
    var restEndDate: Date?
    var restTotalSeconds: Int?
    var nextSetDetail: String?
    /// Rest phase only: true when SKIPPING this rest advances to
    /// `.betweenExercises` (the just-completed set was its exercise's last),
    /// false/nil when it returns to `.activeSet`.
    var restLeadsToBetweenExercises: Bool?
}

@MainActor
final class WorkoutSessionManager {
    var sessionExercises: [(exerciseId: Int, exercise: Exercise)] = []

    func lastCompletedSet() -> WorkoutSet? { nil }
    func isExerciseFullyCompleted(_ exerciseId: Int) -> Bool { false }
    func firstIncompleteSetOfNextExercise(after exerciseId: Int) -> WorkoutSet? { nil }
}

@MainActor
final class WorkoutSnapshotPublisher {
    private unowned let manager: WorkoutSessionManager

    init(manager: WorkoutSessionManager) { self.manager = manager }

    // stub: the real builder assembles the full snapshot (exercise name, set
    // progress, previous performance, ...); collapsed here to just the phase.
    private func buildSnapshot(phase: WorkoutPhase, overrideExercise: Exercise?) -> WorkoutSnapshot {
        WorkoutSnapshot(phase: phase)
    }

    /// Build a rest-phase snapshot with rest-specific fields.
    ///
    /// When the just-completed set was the last of its exercise, the rest
    /// screen's "up next" card must reference the FIRST incomplete set of
    /// the NEXT exercise — not an imaginary extra set of the one just
    /// finished.
    func buildRestSnapshot(restEndDate: Date, restTotalSeconds: Int) -> WorkoutSnapshot {
        let lastCompleted = manager.lastCompletedSet()
        let lastExerciseFullyCompleted = lastCompleted.map {
            manager.isExerciseFullyCompleted($0.exerciseId)
        } ?? false
        let overrideExercise: Exercise? = {
            guard let lastCompleted, lastExerciseFullyCompleted,
                  let nextSet = manager.firstIncompleteSetOfNextExercise(after: lastCompleted.exerciseId) else {
                return nil
            }
            return manager.sessionExercises.first(where: { $0.exerciseId == nextSet.exerciseId })?.exercise
        }()

        var snapshot = buildSnapshot(phase: .rest, overrideExercise: overrideExercise)
        snapshot.restEndDate = restEndDate
        snapshot.restTotalSeconds = restTotalSeconds
        // Same signal the override uses: when the just-completed set was its
        // exercise's last, skipping this rest advances to `.betweenExercises`.
        // The Watch reads this hint to pick the correct optimistic target and
        // avoid a rest→activeSet→between double transition.
        snapshot.restLeadsToBetweenExercises = lastExerciseFullyCompleted

        // When the rest is between exercises (override applied), the user
        // wants to know which exercise is up next — not the weight×reps of
        // its first set. They need to walk to a new station / mentally
        // prepare. Replace the detail with just the exercise name in that
        // case; the within-exercise rest path keeps its weight×reps detail.
        if let override = overrideExercise {
            snapshot.nextSetDetail = override.localizedName
        }

        return snapshot
    }
}

To ideer som fikset alt sammen

Optimistisk UI: forutsi, så avstem

Vis resultatet i det øyeblikket noen handler, og la så kilden til sannhet bekrefte eller rette det. Krone-verdien og fase-omslaget fungerer begge slik. Disiplinen ligger helt og holdent i avstemmings-steget: forutsi bare når utfallet er utvetydig, og nullstill en forutsigelse bare ved en ekte bekreftelse, aldri ved et gammelt ekko. Gjør du det feil, får du feil 1, der en puls nullstilte en verdi den ikke hadde noe med å nullstille, eller den doble overtoningen, der klokka gjettet en fase den ikke kunne se. Gjør du det riktig, føles håndleddet rett og slett umiddelbart.

Én kilde til sannhet

iPhone eier hver bit av økt-tilstanden. Klokka er en skjerm som bare leser, som viser det siste WorkoutSnapshot og sender små WatchCommander tilbake. Ingen delt database, ingen andre kopi som kan drive ut av takt. Hver feil over her var en variant av at klokka stolte på feil kopi, eller at telefonen sendte en gammel en. Når nøyaktig én side eier sannheten, har «hvilken verdi er riktig» alltid et svar, og det er det som gjør resten av systemet enkelt nok til å resonnere om.

Slik føles det nå

Nå henger håndleddet bare med. Rull til vekta di, og den blir stående. Fullfør et sett, og det neste er allerede der. Hopp over en pause, og skjermen flytter seg i det øyeblikket fingeren forlater kronen. Legg til en øvelse på telefonen, og klokka viser den før du har lagt fra deg telefonen. Ingenting av dette er synlig når det virker, og det er akkurat poenget. God synking er noe du bare legger merke til når den mangler.

Musklrs ny rekord-feiring på Apple Watch etter en beinpress på 140 kg, med konfetti.
En ny rekord, oppdaget på telefonen og feiret på håndleddet. Når synkingen er riktig, dukker dette bare opp.

Prøv å logge fra håndleddet

Musklr logger et sett med to trykk, på iPhone og Apple Watch, og det virker offline. Den er gratis å bruke så lenge du vil, uten konto. Pro er valgfritt, rundt 25 kr i måneden, for ekstrafunksjoner som ubegrenset antall rutiner og skysynkronisering. Den er gratis i App Store.

Last ned Musklr i App Store

Se alt Musklr kan på forsiden →