diff --git a/Strand/App/ActiveWorkoutPersistence.swift b/Strand/App/ActiveWorkoutPersistence.swift index 0fc97a4279..c18f0319b2 100644 --- a/Strand/App/ActiveWorkoutPersistence.swift +++ b/Strand/App/ActiveWorkoutPersistence.swift @@ -30,6 +30,8 @@ enum ActiveWorkoutPersistence { var liveStrain: Double var pausedAtSec: Int? = nil var pausedDurationSec: Int? = nil + var sessionID: String? = nil + var pausedForPulseLoss: Bool? = nil } /// The single `UserDefaults` key (JSON-encoded `Snapshot`). Namespaced like `moments`/`sleepMarks`. @@ -60,6 +62,8 @@ enum ActiveWorkoutPersistence { liveStrain: raw.liveStrain.isFinite ? max(0, raw.liveStrain) : 0, pausedAtSec: raw.pausedAtSec.flatMap { $0 > 0 ? $0 : nil }, pausedDurationSec: raw.pausedDurationSec.map { max(0, $0) }, + sessionID: raw.sessionID, + pausedForPulseLoss: raw.pausedForPulseLoss, ) } diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index b477b8b992..24f492d58d 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -124,6 +124,8 @@ final class AppModel: ObservableObject { var peakHr: Int = 0 var pausedAt: Date? var pausedDuration: TimeInterval = 0 + var sessionID = UUID().uuidString + var pausedForPulseLoss = false var isPaused: Bool { pausedAt != nil } @@ -900,7 +902,8 @@ final class AppModel: ObservableObject { peakHr: w.peakHr, liveStrain: w.liveStrain, pausedAtSec: w.pausedAt.map { Int($0.timeIntervalSince1970) }, - pausedDurationSec: Int(w.pausedDuration))) + pausedDurationSec: Int(w.pausedDuration), + sessionID: w.sessionID, pausedForPulseLoss: w.pausedForPulseLoss)) } /// If a manual workout was in flight when iOS killed the app, rebuild `activeWorkout` from the durable @@ -917,6 +920,8 @@ final class AppModel: ObservableObject { w.liveStrain = snap.liveStrain w.pausedAt = snap.pausedAtSec.map { Date(timeIntervalSince1970: TimeInterval($0)) } w.pausedDuration = TimeInterval(snap.pausedDurationSec ?? 0) + w.sessionID = snap.sessionID ?? "workout-\(snap.startSec)" + w.pausedForPulseLoss = snap.pausedForPulseLoss ?? false activeWorkout = w // Rebuild the transient GPS lifecycle flag as well as the durable workout value. Without this, @@ -935,21 +940,35 @@ final class AppModel: ObservableObject { } func toggleWorkoutPause() { - guard var w = activeWorkout else { return } - if let pausedAt = w.pausedAt { - w.pausedDuration += Date().timeIntervalSince(pausedAt) - w.pausedAt = nil - if activeWorkoutIsGps { gpsRecorder.resume() } - } else { - w.pausedAt = Date() - if activeWorkoutIsGps { gpsRecorder.pause() } - } + if activeWorkout?.isPaused == true { resumeWorkout() } else { pauseWorkout() } + } + + func pauseWorkout(at date: Date = Date(), pulseLoss: Bool = false) { + guard var w = activeWorkout, !w.isPaused else { return } + w.pausedAt = max(w.start, date) + w.pausedForPulseLoss = pulseLoss + activeWorkout = w + if activeWorkoutIsGps { gpsRecorder.pause(at: date) } + persistActiveWorkout() + } + + func resumeWorkout(at date: Date = Date()) { + guard var w = activeWorkout, let pausedAt = w.pausedAt, !isFinishingWorkout else { return } + w.pausedDuration += max(0, date.timeIntervalSince(pausedAt)) + w.pausedAt = nil + w.pausedForPulseLoss = false activeWorkout = w + if activeWorkoutIsGps { gpsRecorder.resume() } persistActiveWorkout() } /// Abort the active session without saving a workout. func discardWorkout() { + guard !isFinishingWorkout else { return } + clearActiveWorkout() + } + + private func clearActiveWorkout() { guard activeWorkout != nil else { return } activeWorkout = nil if activeWorkoutIsGps { gpsRecorder.stop() } @@ -973,32 +992,32 @@ final class AppModel: ObservableObject { elapsedSeconds < minimumWorkoutSeconds } - func endWorkout() { + private(set) var isFinishingWorkout = false + + func endWorkout() { Task { try? await finishWorkout() } } + + func finishWorkout() async throws { + guard activeWorkout != nil, !isFinishingWorkout else { return } + isFinishingWorkout = true + defer { isFinishingWorkout = false } + pauseWorkout() guard let w = activeWorkout else { return } - activeWorkout = nil let wasGps = activeWorkoutIsGps - activeWorkoutIsGps = false - // Drop the durable snapshot the instant the session ends , whether it saves below or is discarded - // as too-short , so a relaunch never rehydrates an already-finished session (#529). - ActiveWorkoutPersistence.clear() - // #524: finalize the GPS route. Stop the recorder and take its captured route , it kept + // Keep the paused snapshot and route until the write succeeds, so a failed save remains retryable. + // #524: capture the GPS route; it kept // accumulating from CoreLocation independently of the HR window. `capturedRoute()` is nil unless // ≥2 points actually landed (honest: no route, no distance, when nothing was captured , e.g. a // Mac with no GPS, or denied permission). A non-GPS session never armed the recorder. var route: WorkoutRoute? if wasGps { - gpsRecorder.stop() route = gpsRecorder.capturedRoute() } let samples = w.samples - // Save when there's an HR window OR a real GPS route , a GPS-only walk (HR not streaming) is - // still a workout (parity with Android's `samples.size < 2 && track.size < 2` discard gate). guard samples.count >= 2 || route != nil else { - // Workouts & GPS test mode: record WHY a session vanished (too short / no route), tagged `.workouts`. emitWorkoutsTrace(WorkoutsTrace.sessionLine( event: "discarded", sportKey: WorkoutSource.traceSportKey(w.sport), hrSamples: samples.count, gpsPoints: route == nil ? 0 : nil)) - lastWorkout = nil + clearActiveWorkout() return } let end = Date() @@ -1009,8 +1028,6 @@ final class AppModel: ObservableObject { // and no cloud copy, so a later prune would be irreversible; this is not, because nothing with real // data is ever removed. // - // Sits after the sample/route gate above so that gate's meaning is unchanged: a 30-second session - // can easily carry two HR samples and would otherwise have been saved. let elapsed = w.elapsed(at: end) if Self.isTooShortToSave(elapsedSeconds: elapsed) { emitWorkoutsTrace(WorkoutsTrace.sessionLine( @@ -1019,7 +1036,7 @@ final class AppModel: ObservableObject { gpsPoints: wasGps ? gpsRecorder.pointCount : nil)) // Drop the route too: keeping a polyline for a session that was never saved would orphan it in // RouteStore under a natural key no row claims. - lastWorkout = nil + clearActiveWorkout() return } let avg = samples.isEmpty ? nil @@ -1032,22 +1049,19 @@ final class AppModel: ObservableObject { // disagree with its own re-score. Read once here, at save time; the live readout during the // session is a transient running estimate and deliberately left alone. let restingHR = repo.today?.restingHr.map(Double.init) ?? StrainScorer.defaultRestingHR - let strain = samples.count >= 2 - ? StrainScorer.strain(samples, maxHR: Double(profile.hrMax), - restingHR: restingHR, - method: PuffinExperiment.effortMethod, sex: profile.sex) : nil - // Estimate calories from the captured HR window (same Keytel/Harris–Benedict model the - // auto-detector uses) so a manual session shows energy too, not just duration/strain. (#117) let up = UserProfile(weightKg: profile.weightKg, heightCm: profile.heightCm, age: Double(profile.age), sex: profile.sex) - let kcal = samples.count >= 2 - // #983: same measured resting HR as the strain above, not nil. The calories model's - // active-vs-resting threshold sits at resting + 30% HRR, so the default silently shifts what - // counts as active — and #972 already threads it in the rescore path, so leaving it nil here - // meant a saved workout's kcal disagreed with its own re-score just as its Effort did. - ? Calories.estimateBoutCalories(samples, profile: up, hrmax: Double(profile.hrMax), - restingHR: restingHR).0 - : 0 + let hrMax = Double(profile.hrMax) + let method = PuffinExperiment.effortMethod + let sex = profile.sex + // Pure scoring uses an immutable snapshot, leaving the main actor free for UI and BLE. + let (strain, kcal) = await Task.detached(priority: .userInitiated) { + let strain = samples.count >= 2 + ? StrainScorer.strain(samples, maxHR: hrMax, restingHR: restingHR, method: method, sex: sex) : nil + let kcal = samples.count >= 2 + ? Calories.estimateBoutCalories(samples, profile: up, hrmax: hrMax, restingHR: restingHR).0 : 0 + return (strain, kcal) + }.value let startTs = Int(w.start.timeIntervalSince1970) let row = WorkoutRow( startTs: startTs, endTs: Int(end.timeIntervalSince1970), @@ -1060,6 +1074,9 @@ final class AppModel: ObservableObject { // Persist the route polyline under the row's natural key so WorkoutDetailView can draw it. On // device only; mirrors the moments / sleepMarks UserDefaults persistence. (#524) if let route { RouteStore.store(route, startTs: startTs, sport: w.sport) } + guard let store = await repo.storeHandle() else { throw CocoaError(.fileWriteUnknown) } + _ = try await store.upsertWorkouts([row], deviceId: deviceId) + clearActiveWorkout() lastWorkout = row // Workouts & GPS test mode: one session-end summary tagged `.workouts` (the lastSessionSummary readout // source) carrying the captured HR window size, the duration, and the accepted GPS point count, so the @@ -1070,13 +1087,7 @@ final class AppModel: ObservableObject { durationSec: Int(w.elapsed(at: end)), gpsPoints: wasGps ? gpsRecorder.pointCount : nil)) buzz(loops: 2, gate: HapticPrefs.workout) - Task { [weak self] in - guard let self else { return } - if let store = await self.repo.storeHandle() { - _ = try? await store.upsertWorkouts([row], deviceId: self.deviceId) - await self.repo.refresh() - } - } + await repo.refresh() } /// Append the current smoothed `bpm` to the active workout and recompute its running strain. Called diff --git a/Strand/App/GpsWorkoutRecorder.swift b/Strand/App/GpsWorkoutRecorder.swift index ad7cbb0e97..d3547124ee 100644 --- a/Strand/App/GpsWorkoutRecorder.swift +++ b/Strand/App/GpsWorkoutRecorder.swift @@ -594,9 +594,9 @@ final class GpsWorkoutRecorder: NSObject, ObservableObject { return final } - func pause() { + func pause(at date: Date = Date()) { guard isRecording else { return } - pausedAtMs = Int64(Date().timeIntervalSince1970 * 1000) + pausedAtMs = Int64(date.timeIntervalSince1970 * 1000) manager.stopUpdatingLocation() isRecording = false } diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index 72c3ca1bbf..ad9ddb4251 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -29,6 +29,7 @@ final class LiftSessionController: ObservableObject { @Published private(set) var programName: String? /// True while the full sheet is presented; false when minimised to the bottom bar. @Published var isPresented = false + @Published var sessionRpeText = "" { didSet { persist() } } /// Bumped each time a finished session is written. The session sheet is presented above every /// screen, so its save cannot call back into the one listing sessions; that screen reloads on this. @@ -50,6 +51,22 @@ final class LiftSessionController: ObservableObject { .eraseToAnyPublisher() var isActive: Bool { engine != nil && engine?.isFinished == false } + private(set) var sessionID = UUID().uuidString + private(set) var pausedForPulseLoss = false + + func pause(at now: Int = LiftSessionController.unixNow, pulseLoss: Bool = false) { + guard isActive, engine?.isPaused == false else { return } + pausedForPulseLoss = pulseLoss + engine?.pause(now: now) + persist() + } + + func resume(at now: Int = LiftSessionController.unixNow) { + guard !isSaving, engine?.isPaused == true else { return } + pausedForPulseLoss = false + engine?.resume(now: now) + persist() + } /// Rest period the five-second warning has already fired for. Lives HERE, not in a view, so /// re-opening the sheet mid-rest cannot re-fire it. @@ -126,9 +143,101 @@ final class LiftSessionController: ObservableObject { self.log = log } + func start(program: LiftProgramRow, repo: Repository, present: Bool = true) async throws { + guard !isActive else { if present { isPresented = true }; return } + guard let store = await repo.storeHandle() else { throw CocoaError(.fileReadUnknown) } + let items = try await store.liftProgramItems(programId: program.id) + guard !items.isEmpty else { throw CocoaError(.fileReadNoSuchFile) } + let vocabulary = try await store.liftExercises(deviceId: repo.deviceId) + + let plan = items.map { item -> LiftPlanItem in + // The classification comes from the exercise vocabulary, which is the one place that owns + // it — the program line deliberately stores no muscle of its own to drift from. + let known = vocabulary.first { $0.name == item.exercise } + return LiftPlanItem(exercise: item.exercise, + primaryMuscle: known?.primaryMuscle, + secondaryMuscles: known?.secondaryMuscles ?? [], + targetSets: item.targetSets, + restSec: item.restSec, + targetRepsLow: item.targetRepsLow, + targetRepsHigh: item.targetRepsHigh, + targetRpe: item.targetRpe, + targetWeightKg: item.targetWeightKg, + note: item.note, + // Carried so a set added or dropped mid-session can be written back + // onto the line it came from, and be there next time. + programItemId: item.id) + } + guard !isActive else { if present { isPresented = true }; return } + start(plan: plan, programId: program.id, programName: program.name) + isPresented = present + await loadLastSession(repo: repo) + } + + func loadLastSession(repo: Repository) async { + guard let engine, let store = await repo.storeHandle() else { return } + let id = sessionID + let exercises = Set(engine.plan.map(\.exercise)) + guard Set(lastSession.keys) != exercises else { return } + var out: [String: [Int: LiftSetCarry]] = [:] + for exercise in exercises { + let rows = (try? await store.lastLiftSets(deviceId: repo.deviceId, exercise: exercise, before: engine.startTs)) ?? [] + var bySet: [Int: LiftSetCarry] = [:] + for row in rows where !row.isWarmup { bySet[row.setIndex] = LiftSetCarry(weightKg: row.weightKg, reps: row.reps) } + out[exercise] = bySet + } + guard sessionID == id, Set(self.engine?.plan.map(\.exercise) ?? []) == exercises else { return } + setLastSession(out) + } + + private(set) var isSaving = false + + /// Retains the paused session until all writes succeed; stable IDs make a retry idempotent. + func save(repo: Repository, completingUnfinished: Bool, sessionRpe: Double?) async throws -> (plan: [LiftPlanItem], sets: [FinishedSet]) { + guard !isSaving, engine != nil else { throw CocoaError(.userCancelled) } + isSaving = true + defer { isSaving = false } + pause() + guard var finishedEngine = engine, let store = await repo.storeHandle() else { throw CocoaError(.fileWriteUnknown) } + let endTs = Self.unixNow + let duration = finishedEngine.elapsed(now: endTs) + finishedEngine.finish(now: endTs) + let finished = setsToSave(completingUnfinished: completingUnfinished, using: finishedEngine) + if Self.anyPerformed(finished) { + let row = LiftSessionRow(id: sessionID, deviceId: repo.deviceId, startTs: finishedEngine.startTs, + endTs: endTs, sport: "Strength", programId: programId, + programName: programName, sessionRpe: sessionRpe ?? LiftFormat.number(sessionRpeText), note: programName) + _ = try await store.upsertLiftSessions([row]) + let rows = finished.enumerated().map { ord, set in + let item = finishedEngine.planItem(for: set.slot) + return LiftSetRow(id: "\(sessionID):\(set.slot.exerciseIndex):\(set.slot.setIndex)", + deviceId: repo.deviceId, sessionId: sessionID, ord: ord, + exercise: item?.exercise ?? "", primaryMuscle: item?.primaryMuscle, + secondaryMuscles: item?.secondaryMuscles ?? [], setIndex: set.slot.setIndex, + weightKg: set.weightKg, reps: set.reps, rpe: set.rpe, isWarmup: set.isWarmup, + startTs: set.startTs, endTs: set.endTs, restSec: set.restSec, note: nil) + } + let existing = try await store.liftSets(sessionId: sessionID) + let savedIDs = Set(rows.map(\.id)) + _ = try await store.upsertLiftSets(rows) + _ = try await store.deleteLiftSets(ids: existing.filter { !savedIDs.contains($0.id) }.map(\.id)) + let workout = WorkoutRow(startTs: finishedEngine.startTs, endTs: endTs, sport: "Strength", source: "manual", + durationS: Double(duration), energyKcal: nil, avgHr: nil, maxHr: nil, strain: nil, + distanceM: nil, zonesJSON: nil, notes: programName, steps: nil) + _ = try await store.upsertWorkouts([workout], deviceId: repo.deviceId) + + } + return (finishedEngine.plan, finished) + } + // MARK: - Lifecycle func start(plan: [LiftPlanItem], programId: String?, programName: String?) { + guard !isActive else { return } + sessionID = UUID().uuidString + lastSession = [:] + pausedForPulseLoss = false + sessionRpeText = "" let stamp = Int(Date().timeIntervalSince1970) engine = LiftSessionEngine(plan: plan, startTs: stamp) self.programId = programId @@ -158,6 +267,9 @@ final class LiftSessionController: ObservableObject { /// Rehydrate an interrupted session found on disk. Does NOT present the sheet: the session comes /// back as the bottom bar, and the user opens it if they want to. func resume(from snapshot: LiftSessionPersistence.Snapshot, present: Bool = false) { + lastSession = [:] + sessionID = snapshot.sessionID ?? "lift-\(snapshot.startSec)" + pausedForPulseLoss = snapshot.pausedForPulseLoss ?? false engine = LiftSessionPersistence.engine(from: snapshot) programId = snapshot.programId programName = snapshot.programName @@ -176,11 +288,13 @@ final class LiftSessionController: ObservableObject { warnedFor = nil } isPresented = present + sessionRpeText = snapshot.sessionRpeText ?? "" claimStrap() } /// Give up the session without saving. func discard() { + guard !isSaving else { return } teardown() LiftSessionPersistence.clear() } @@ -194,6 +308,7 @@ final class LiftSessionController: ObservableObject { private func teardown() { engine = nil + sessionRpeText = "" programId = nil programName = nil warnedFor = nil @@ -213,13 +328,13 @@ final class LiftSessionController: ObservableObject { } /// The current unix second. Read when needed; nothing about a session is stored per second. - static var unixNow: Int { Int(Date().timeIntervalSince1970) } + nonisolated static var unixNow: Int { Int(Date().timeIntervalSince1970) } // MARK: - Actions /// The one action. `fromStrap` earns a single confirming buzz, unless the tap reads as a knock. func advance(fromStrap: Bool = false) { - guard let current = engine else { return } + guard !isSaving, let current = engine, !current.isPaused else { return } let stamp = Int(Date().timeIntervalSince1970) if fromStrap { if let last = lastStrapStepAt, @@ -291,7 +406,12 @@ final class LiftSessionController: ObservableObject { func presentation(system: UnitSystem) -> Presentation? { guard let engine, !engine.isFinished else { return nil } - let started = Date(timeIntervalSince1970: TimeInterval(engine.stageStartedAt)) + let started = Date(timeIntervalSince1970: TimeInterval(engine.stageStartedAt + engine.stagePausedDuration)) + if engine.isPaused { + return Presentation(isResting: false, exercise: engine.currentSlot.flatMap { engine.planItem(for: $0)?.exercise } ?? programName ?? String(localized: "Session"), + status: String(localized: "Paused"), detail: nil, next: Self.nextLine(engine), + stageStartedAt: started, restEndsAt: nil) + } let next = Self.nextLine(engine) guard let slot = engine.currentSlot, let item = engine.planItem(for: slot) else { @@ -536,8 +656,8 @@ final class LiftSessionController: ObservableObject { /// (`LiftMetrics.isPerformed`) and Edit sets still shows, so a discard made by mistake can be filled /// back in. Done sets keep the order they happened in and their timing; sets never started follow in /// plan order, with no timing. - func setsToSave(completingUnfinished: Bool) -> [FinishedSet] { - guard let engine else { return [] } + func setsToSave(completingUnfinished: Bool, using override: LiftSessionEngine? = nil) -> [FinishedSet] { + guard let engine = override ?? engine else { return [] } // The plan's max RPE, which the session shows grey in the RPE field. func planned(_ slot: LiftSlot) -> Double? { engine.planItem(for: slot)?.targetRpe } var out = engine.sets.map { set -> FinishedSet in @@ -677,7 +797,7 @@ final class LiftSessionController: ObservableObject { /// Re-arm the rest's timers when the rest changed — a new rest, a rest undone or cut short, no rest. private func scheduleRestTimers() { - let endsAt: Int? = { if case .resting(_, let end) = engine?.stage { return end }; return nil }() + let endsAt: Int? = { if engine?.isPaused == false, case .resting(_, let end) = engine?.stage { return end }; return nil }() guard endsAt != scheduledRestEnd else { return } scheduledRestEnd = endsAt restTimers.forEach { $0.cancel() } @@ -710,7 +830,7 @@ final class LiftSessionController: ObservableObject { } private func fireRestWarningIfDue() { - guard let engine, case .resting(_, let endsAt) = engine.stage else { return } + guard let engine, !engine.isPaused, case .resting(_, let endsAt) = engine.stage else { return } guard warnedFor != endsAt else { return } guard endsAt - Self.unixNow <= LiftSessionController.restWarningLeadSec else { return } warnedFor = endsAt @@ -727,11 +847,14 @@ final class LiftSessionController: ObservableObject { private func persist() { guard let engine, !engine.isFinished else { return } - LiftSessionPersistence.store( - LiftSessionPersistence.snapshot(engine: engine, + var snapshot = LiftSessionPersistence.snapshot(engine: engine, programId: programId, programName: programName, pendingValues: pendingValues, - pendingWarmups: pendingWarmups)) + pendingWarmups: pendingWarmups) + snapshot.sessionID = sessionID + snapshot.pausedForPulseLoss = pausedForPulseLoss + snapshot.sessionRpeText = sessionRpeText.isEmpty ? nil : sessionRpeText + LiftSessionPersistence.store(snapshot) } } diff --git a/Strand/Data/LiftSessionEngine.swift b/Strand/Data/LiftSessionEngine.swift index 3e33e6e490..f8e3d3033c 100644 --- a/Strand/Data/LiftSessionEngine.swift +++ b/Strand/Data/LiftSessionEngine.swift @@ -135,6 +135,39 @@ struct LiftSessionEngine: Equatable { private(set) var sets: [LiftRecordedSet] /// When the current stage began, so a set's duration is measurable. private(set) var stageStartedAt: Int + private(set) var pausedAt: Int? + private(set) var pausedDuration = 0 + private(set) var stagePausedDuration = 0 + var isPaused: Bool { pausedAt != nil } + + func elapsed(now: Int) -> Int { + Int(ActiveWorkoutClock.activeElapsed( + start: Date(timeIntervalSince1970: Double(startTs)), + pausedAt: pausedAt.map { Date(timeIntervalSince1970: Double($0)) }, + pausedDuration: Double(pausedDuration), now: Date(timeIntervalSince1970: Double(now)))) + } + + func stageElapsed(now: Int) -> Int { max(0, (pausedAt ?? now) - stageStartedAt - stagePausedDuration) } + + mutating func pause(now: Int) { + guard !isPaused, !isFinished else { return } + pausedAt = max(startTs, now) + } + + mutating func resume(now: Int) { + guard let pausedAt else { return } + let gap = max(0, now - pausedAt) + pausedDuration += gap + stagePausedDuration += gap + if case .resting(let slot, let endsAt) = stage { stage = .resting(slot, endsAt: endsAt + gap) } + for i in history.indices { + history[i].stagePausedDuration += gap + if case .resting(let slot, let end) = history[i].stage { + history[i].stage = .resting(slot, endsAt: end + gap) + } + } + self.pausedAt = nil + } /// Undo stack: whole-state snapshots rather than inverse operations. A gym is a bad place to be /// one tap ahead of yourself, and restoring a snapshot cannot get the arithmetic wrong the way a @@ -151,6 +184,7 @@ struct LiftSessionEngine: Equatable { var stage: Stage var sets: [LiftRecordedSet] var stageStartedAt: Int + var stagePausedDuration: Int } init(plan: [LiftPlanItem], startTs: Int) { @@ -166,12 +200,16 @@ struct LiftSessionEngine: Equatable { /// The undo history is deliberately NOT restored: it is a within-sitting convenience, and a /// stack that survives a relaunch invites reaching back past a save boundary. init(restoring plan: [LiftPlanItem], startTs: Int, stage: Stage, - sets: [LiftRecordedSet], stageStartedAt: Int) { + sets: [LiftRecordedSet], stageStartedAt: Int, pausedAt: Int? = nil, + pausedDuration: Int = 0, stagePausedDuration: Int = 0) { self.plan = plan self.startTs = startTs self.stage = stage self.sets = sets self.stageStartedAt = stageStartedAt + self.pausedAt = pausedAt + self.pausedDuration = max(0, pausedDuration) + self.stagePausedDuration = max(0, stagePausedDuration) } // MARK: - The sheet @@ -260,7 +298,7 @@ struct LiftSessionEngine: Equatable { /// actually means: the user has not moved on yet. func restRemaining(now: Int) -> Int? { guard case .resting(_, let endsAt) = stage else { return nil } - return max(0, endsAt - now) + return max(0, endsAt - (pausedAt ?? now)) } /// The nearest earlier set of this exercise already performed in THIS session. Nil for the first @@ -322,15 +360,17 @@ struct LiftSessionEngine: Equatable { /// because nothing was finished. Starting an already-completed slot re-opens it for a redo, /// dropping its previous record so the set is not counted twice. mutating func start(_ slot: LiftSlot, now: Int) { - guard planItem(for: slot) != nil else { return } + guard !isPaused, planItem(for: slot) != nil else { return } pushHistory() sets.removeAll { $0.slot == slot } stage = .working(slot) stageStartedAt = now + stagePausedDuration = 0 } /// The big button. Context decides what it means. mutating func advance(now: Int) { + guard !isPaused else { return } switch stage { case .warmup: guard let next = nextPendingSlot else { return } @@ -346,22 +386,25 @@ struct LiftSessionEngine: Equatable { let rest = planItem(for: slot)?.restSec ?? LiftPlanItem.defaultRestSec stage = .resting(slot, endsAt: now + rest) stageStartedAt = now + stagePausedDuration = 0 case .resting(let slot, _): pushHistory() // Record what was ACTUALLY rested — the figure each set row shows, and the one thing // only the taps can know. if let i = sets.firstIndex(where: { $0.slot == slot }) { - sets[i].restSec = max(0, now - stageStartedAt) + sets[i].restSec = stageElapsed(now: now) } if let next = slotAfter(slot) { stage = .working(next) stageStartedAt = now + stagePausedDuration = 0 } else { // Sheet complete: stay put rather than inventing a stage. The user finishes when // they are ready, and the time until then is the cool-down. stage = .resting(slot, endsAt: now) stageStartedAt = now + stagePausedDuration = 0 } case .finished: @@ -453,22 +496,25 @@ struct LiftSessionEngine: Equatable { pushHistory() if case .resting(let slot, _) = stage, let i = sets.firstIndex(where: { $0.slot == slot }), sets[i].restSec == nil { - sets[i].restSec = max(0, now - stageStartedAt) + sets[i].restSec = stageElapsed(now: now) } stage = .finished stageStartedAt = now + stagePausedDuration = 0 } mutating func undo() { + guard !isPaused else { return } guard let previous = history.popLast() else { return } plan = previous.plan stage = previous.stage sets = previous.sets stageStartedAt = previous.stageStartedAt + stagePausedDuration = previous.stagePausedDuration } private mutating func pushHistory() { history.append(Snapshot(plan: plan, stage: stage, - sets: sets, stageStartedAt: stageStartedAt)) + sets: sets, stageStartedAt: stageStartedAt, stagePausedDuration: stagePausedDuration)) } } diff --git a/Strand/Data/LiftSessionPersistence.swift b/Strand/Data/LiftSessionPersistence.swift index e6017fe333..90e0a4e2fd 100644 --- a/Strand/Data/LiftSessionPersistence.swift +++ b/Strand/Data/LiftSessionPersistence.swift @@ -35,6 +35,12 @@ enum LiftSessionPersistence { /// as absent and the session resumes with nothing pending, which is exactly right. var pendingValues: [PendingValue]? var pendingWarmups: [SlotBox]? + var sessionID: String? = nil + var pausedAt: Int? = nil + var pausedDuration: Int? = nil + var stagePausedDuration: Int? = nil + var pausedForPulseLoss: Bool? = nil + var sessionRpeText: String? = nil struct PendingValue: Codable, Equatable { var exerciseIndex: Int @@ -176,7 +182,10 @@ enum LiftSessionPersistence { }, pendingWarmups: pendingWarmups .sorted { ($0.exerciseIndex, $0.setIndex) < ($1.exerciseIndex, $1.setIndex) } - .map { Snapshot.SlotBox(exerciseIndex: $0.exerciseIndex, setIndex: $0.setIndex) }) + .map { Snapshot.SlotBox(exerciseIndex: $0.exerciseIndex, setIndex: $0.setIndex) }, + pausedAt: engine.pausedAt, + pausedDuration: engine.pausedDuration == 0 ? nil : engine.pausedDuration, + stagePausedDuration: engine.stagePausedDuration == 0 ? nil : engine.stagePausedDuration) } /// The numbers typed in advance, back as the controller holds them. @@ -219,7 +228,9 @@ enum LiftSessionPersistence { restSec: $0.restSec) } return LiftSessionEngine(restoring: plan, startTs: s.startSec, - stage: unbox(s.stage), sets: sets, stageStartedAt: s.stageStartedAt) + stage: unbox(s.stage), sets: sets, stageStartedAt: s.stageStartedAt, + pausedAt: s.pausedAt, pausedDuration: s.pausedDuration ?? 0, + stagePausedDuration: s.stagePausedDuration ?? 0) } private static func box(_ stage: LiftSessionEngine.Stage) -> Snapshot.StageBox { diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index 3b05714075..bfe7ba7034 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -243,7 +243,10 @@ final class Repository: ObservableObject { workoutsLog(build()) } - init(deviceId: String) { self.deviceId = deviceId } + init(deviceId: String, store: WhoopStore? = nil) { + self.deviceId = deviceId + self.store = store + } /// Re-point the read model's ACTIVE-strap id at the device registry's active device, so a re-added /// strap's LIVE raw (written under its fresh "whoop-" id) surfaces on the dashboard (#814). diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 7f227851a7..6ca969c2e3 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,8 @@ { "sourceLanguage": "en", "strings": { + "Training could not be saved": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Training could not be saved"}}, "de": {"stringUnit": {"state": "translated", "value": "Training konnte nicht gespeichert werden"}}, "es": {"stringUnit": {"state": "translated", "value": "No se pudo guardar el entrenamiento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Impossible d’enregistrer la séance"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Não foi possível guardar o treino"}}, "it": {"stringUnit": {"state": "translated", "value": "Impossibile salvare l’allenamento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Nie udało się zapisać treningu"}}, "ru": {"stringUnit": {"state": "translated", "value": "Не удалось сохранить тренировку"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "无法保存训练"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "無法儲存訓練"}}}}, + "Your paused training is still available. Please try again.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Your paused training is still available. Please try again."}}, "de": {"stringUnit": {"state": "translated", "value": "Dein pausiertes Training bleibt erhalten. Bitte versuche es erneut."}}, "es": {"stringUnit": {"state": "translated", "value": "Tu entrenamiento pausado sigue disponible. Vuelve a intentarlo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Votre séance en pause est conservée. Réessayez."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "O treino em pausa continua disponível. Tente novamente."}}, "it": {"stringUnit": {"state": "translated", "value": "L’allenamento in pausa è conservato. Riprova."}}, "pl": {"stringUnit": {"state": "translated", "value": "Wstrzymany trening jest zachowany. Spróbuj ponownie."}}, "ru": {"stringUnit": {"state": "translated", "value": "Тренировка на паузе сохранена. Повторите попытку."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "已暂停的训练仍然保留。请重试。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "已暫停的訓練仍然保留。請重試。"}}}}, "Move, relative to tonight": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Move, relative to tonight"}}, "de": {"stringUnit": {"state": "translated", "value": "Bewegung, relativ zu dieser Nacht"}}, "es": {"stringUnit": {"state": "translated", "value": "Movimiento, en relación con esta noche"}}, "fr": {"stringUnit": {"state": "translated", "value": "Mouvement, par rapport à cette nuit"}}, "it": {"stringUnit": {"state": "translated", "value": "Movimento, rispetto a questa notte"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ruch, w odniesieniu do tej nocy"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Movimento, em relação a esta noite"}}, "ru": {"stringUnit": {"state": "translated", "value": "Движение, относительно этой ночи"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "活动,相对于今晚"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "活動,相對於今晚"}}}}, "Route and GPS distance, without the heart-rate and calorie summary": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Route and GPS distance, without the heart-rate and calorie summary"}}, "de": {"stringUnit": {"state": "translated", "value": "Route und GPS-Distanz, ohne die Herzfrequenz- und Kalorienübersicht"}}, "es": {"stringUnit": {"state": "translated", "value": "Ruta y distancia GPS, sin el resumen de frecuencia cardiaca y calorías"}}, "fr": {"stringUnit": {"state": "translated", "value": "Itinéraire et distance GPS, sans le récapitulatif de fréquence cardiaque et de calories"}}, "it": {"stringUnit": {"state": "translated", "value": "Percorso e distanza GPS, senza il riepilogo di frequenza cardiaca e calorie"}}, "pl": {"stringUnit": {"state": "translated", "value": "Trasa i dystans GPS, bez podsumowania tętna i kalorii"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Rota e distância GPS, sem o resumo de frequência cardíaca e calorias"}}, "ru": {"stringUnit": {"state": "translated", "value": "Маршрут и GPS-дистанция, без сводки пульса и калорий"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "路线和 GPS 距离,不含心率和卡路里摘要"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "路線和 GPS 距離,不含心率和卡路里摘要"}}}}, "Export Workout Route": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Export Workout Route"}}, "de": {"stringUnit": {"state": "translated", "value": "Trainingsroute exportieren"}}, "es": {"stringUnit": {"state": "translated", "value": "Exportar ruta del entrenamiento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Exporter l’itinéraire de la séance"}}, "it": {"stringUnit": {"state": "translated", "value": "Esporta il percorso dell’allenamento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Eksportuj trasę treningu"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Exportar rota do treino"}}, "ru": {"stringUnit": {"state": "translated", "value": "Экспортировать маршрут тренировки"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "导出运动路线"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "匯出運動路線"}}}}, diff --git a/Strand/Screens/LiftLogView.swift b/Strand/Screens/LiftLogView.swift index ce72933b6f..2be4ed6d7e 100644 --- a/Strand/Screens/LiftLogView.swift +++ b/Strand/Screens/LiftLogView.swift @@ -186,36 +186,7 @@ struct LiftLogView: View { /// Flatten a program into the plan the session runs. The plan is SNAPSHOT at start: editing or /// deleting the program mid-session cannot change what is being tapped through. private func start(_ program: LiftProgramRow) async { - guard let store = await repo.storeHandle() else { return } - let items = (try? await store.liftProgramItems(programId: program.id)) ?? [] - guard !items.isEmpty else { return } - let vocabulary = (try? await store.liftExercises(deviceId: repo.deviceId)) ?? [] - - let plan = items.map { item -> LiftPlanItem in - // The classification comes from the exercise vocabulary, which is the one place that owns - // it — the program line deliberately stores no muscle of its own to drift from. - let known = vocabulary.first { $0.name == item.exercise } - return LiftPlanItem(exercise: item.exercise, - primaryMuscle: known?.primaryMuscle, - secondaryMuscles: known?.secondaryMuscles ?? [], - targetSets: item.targetSets, - restSec: item.restSec, - targetRepsLow: item.targetRepsLow, - targetRepsHigh: item.targetRepsHigh, - targetRpe: item.targetRpe, - targetWeightKg: item.targetWeightKg, - note: item.note, - // Carried so a set added or dropped mid-session can be written back - // onto the line it came from, and be there next time. - programItemId: item.id) - } - // Refuse to start a second session over a running one: two live sessions would both claim - // the strap gesture and both write the in-flight snapshot. - guard !session.isActive else { - session.isPresented = true - return - } - session.start(plan: plan, programId: program.id, programName: program.name) + try? await session.start(program: program, repo: repo) } // MARK: - This week, per muscle diff --git a/Strand/Screens/LiftSessionBar.swift b/Strand/Screens/LiftSessionBar.swift index a4a01f6bcb..216252393d 100644 --- a/Strand/Screens/LiftSessionBar.swift +++ b/Strand/Screens/LiftSessionBar.swift @@ -77,8 +77,8 @@ struct LiftSessionBar: View { // The same action the sheet's button performs, so a set can be closed out // without opening anything. - Button { session.advance() } label: { - Image(systemName: "checkmark.circle.fill") + Button { if engine.isPaused { session.resume() } else { session.advance() } } label: { + Image(systemName: engine.isPaused ? "play.circle.fill" : "checkmark.circle.fill") .font(.system(size: 26, weight: .semibold)) .foregroundStyle(tint(engine)) } @@ -108,6 +108,6 @@ struct LiftSessionBar: View { /// Rest counts DOWN (that is the number you act on); everything else counts up. Written as the Lock /// Screen writes the same clock — "0:45", "0:00", "1:05:00" — through NOOP's one running-clock format. private func bigClock(_ engine: LiftSessionEngine) -> LiftRunningClock { - LiftRunningClock { now in engine.restRemaining(now: now) ?? now - engine.stageStartedAt } + LiftRunningClock { now in engine.restRemaining(now: now) ?? engine.stageElapsed(now: now) } } } diff --git a/Strand/Screens/LiftSessionDetailSheet.swift b/Strand/Screens/LiftSessionDetailSheet.swift index 4871ef0b6e..7d0cfa844d 100644 --- a/Strand/Screens/LiftSessionDetailSheet.swift +++ b/Strand/Screens/LiftSessionDetailSheet.swift @@ -40,6 +40,7 @@ struct LiftSessionDetailSheet: View { private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } private var durationSec: Int { + if let duration = workout?.durationS { return max(0, Int(duration)) } guard let end = session.endTs else { return 0 } return max(0, end - session.startTs) } diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index 6c6e78c928..d952ce64e6 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -30,8 +30,8 @@ struct LiftSessionView: View { @State private var showingFinish = false @State private var confirmingDiscard = false - @State private var sessionRpeText = "" @State private var saving = false + @State private var saveError = false /// The two questions finishing can ask. Nil until answered: saving waits for an answer rather than /// deciding for the user. @State private var unfinishedChoice: UnfinishedChoice? @@ -91,7 +91,7 @@ struct LiftSessionView: View { .dismissesKeyboardOnTap($focused) // Re-read whenever the session's exercises change, so an exercise added mid-session that was // done before shows last time's numbers in grey, like every other line. - .task(id: engine?.plan.map(\.exercise)) { await loadLastTime() } + .task(id: engine?.plan.map(\.exercise)) { await session.loadLastSession(repo: repo) } // Release a field's draft once the user leaves it, so the row returns to the canonical // formatting ("45.50" typed becomes "45.5"). The single-argument form on purpose: the // two-argument `onChange` is macOS 14+ and this file also builds for macOS 13. @@ -550,8 +550,11 @@ struct LiftSessionView: View { } HStack(spacing: NoopMetrics.rowSpacing) { - Button { session.advance() } label: { - Text(actionLabel(engine)).frame(maxWidth: .infinity) + if !engine.isPaused { + Button("Pause") { session.pause() }.buttonStyle(.noopSecondary) + } + Button { if engine.isPaused { session.resume() } else { session.advance() } } label: { + Text(engine.isPaused ? "Resume" : actionLabel(engine)).frame(maxWidth: .infinity) } .buttonStyle(.noopPrimary) @@ -607,7 +610,7 @@ struct LiftSessionView: View { switch engine.stage { case .working: clock(String(localized: "This set"), tint: StrandPalette.statusPositive) { - $0 - engine.stageStartedAt + engine.stageElapsed(now: $0) } case .resting: // "Rest period", never "Rest": the catalog's "Rest" key is NOOP's SLEEP metric, so this @@ -618,7 +621,7 @@ struct LiftSessionView: View { } case .warmup, .finished: clock(String(localized: "Warm-up"), tint: StrandPalette.textSecondary) { - $0 - engine.stageStartedAt + engine.stageElapsed(now: $0) } } } @@ -645,7 +648,7 @@ struct LiftSessionView: View { NoopCard { VStack(alignment: .leading, spacing: NoopMetrics.gap) { Text("How hard was the whole session? (1–10)").strandOverline() - TextField("7", text: $sessionRpeText) + TextField("7", text: $session.sessionRpeText) .textFieldStyle(.plain) .font(StrandFont.bodyNumber) .foregroundStyle(StrandPalette.textPrimary) @@ -705,6 +708,9 @@ struct LiftSessionView: View { #endif .background(StrandPalette.surfaceBase) .keyboardDoneToolbar($focused) + .alert("Training could not be saved", isPresented: $saveError) { + Button("OK", role: .cancel) {} + } message: { Text("Your paused training is still available. Please try again.") } .task { await loadSetCountChanges() } } @@ -789,89 +795,20 @@ struct LiftSessionView: View { // MARK: - Loading and saving - /// What was lifted for each of this session's exercises LAST time, by set number — the middle - /// layer of the grey numbers, handed to the controller that owns the chain. - private func loadLastTime() async { - guard let engine, let store = await repo.storeHandle() else { return } - var out: [String: [Int: LiftSetCarry]] = [:] - // One query per DISTINCT exercise, not per plan line. A program that programs the same - // movement twice — or an imported one with many lines — would otherwise re-ask the store the - // same question, and this runs when the sheet opens. - for exercise in NSOrderedSet(array: engine.plan.map(\.exercise)).compactMap({ $0 as? String }) { - let rows = (try? await store.lastLiftSets(deviceId: repo.deviceId, - exercise: exercise, - before: engine.startTs)) ?? [] - var bySet: [Int: LiftSetCarry] = [:] - for r in rows where !r.isWarmup { - bySet[r.setIndex] = LiftSetCarry(weightKg: r.weightKg, reps: r.reps) - } - out[exercise] = bySet - } - session.setLastSession(out) - } - private func save() async { guard !saving, let store = await repo.storeHandle() else { return } saving = true defer { saving = false } - session.finish() - guard let engine = session.engine else { return } - let endTs = Int(Date().timeIntervalSince1970) - let sessionId = UUID().uuidString - // After `finish`, which closes out the running rest: that set's measured rest belongs to it. - let finished = session.setsToSave(completingUnfinished: unfinishedChoice == .complete) - - // Nothing to file, so file nothing. With no set done, "Discard them" turns every set into a zero. - // Filing that anyway wrote a session with nothing in it AND a manual workout, and the engine fills - // that workout's strain from the heart rate the strap measured — so an hour that recorded nothing - // still read back as a workout. The finish sheet says so before Save. The program's set counts - // are a separate thing the user chose explicitly, so those still apply. - guard LiftSessionController.anyPerformed(finished) else { - await writeProgram(store: store, plan: engine.plan, sets: finished) - await finishAndDismiss() + do { + let saved = try await session.save(repo: repo, completingUnfinished: unfinishedChoice == .complete, + sessionRpe: LiftFormat.number(session.sessionRpeText)) + await writeProgram(store: store, plan: saved.plan, sets: saved.sets) + } catch { + saveError = true return } - let row = LiftSessionRow( - id: sessionId, deviceId: repo.deviceId, - startTs: engine.startTs, endTs: endTs, sport: LiftSessionView.sport, - programId: session.programId, - // Snapshot the name: renaming or deleting the program never rewrites this session. - programName: session.programName, - sessionRpe: LiftFormat.number(sessionRpeText), - note: session.programName) - _ = try? await store.upsertLiftSessions([row]) - - // `ord` is COMPLETION order, which with out-of-order work is not the plan's order — and it - // is the order that actually happened, which is what a session should read back as. Sets - // completed at finish without being started come last. - let rows = finished.enumerated().map { ord, s -> LiftSetRow in - let item = engine.planItem(for: s.slot) - return LiftSetRow( - id: UUID().uuidString, deviceId: repo.deviceId, sessionId: sessionId, - ord: ord, exercise: item?.exercise ?? "", - // Snapshot the classification AS IT WAS, so reclassifying later never rewrites what - // past weeks were counted as. - primaryMuscle: item?.primaryMuscle, - secondaryMuscles: item?.secondaryMuscles ?? [], - setIndex: s.slot.setIndex, weightKg: s.weightKg, reps: s.reps, rpe: s.rpe, - isWarmup: s.isWarmup, startTs: s.startTs, endTs: s.endTs, - restSec: s.restSec, note: nil) - } - _ = try? await store.upsertLiftSets(rows) - await writeProgram(store: store, plan: engine.plan, sets: finished) - - // Through the SAME path a manual workout takes, so it inherits overlap dedup, the engine's - // HR-derived strain fill and delete/merge. `strain` stays nil deliberately: the engine fills - // it from the heart rate the strap MEASURED, never from typed sets and reps. - let workout = WorkoutRow( - startTs: engine.startTs, endTs: endTs, sport: LiftSessionView.sport, - source: "manual", durationS: Double(max(0, endTs - engine.startTs)), - energyKcal: nil, avgHr: nil, maxHr: nil, strain: nil, - distanceM: nil, zonesJSON: nil, notes: session.programName, steps: nil) - await repo.saveManualWorkout(workout) - await finishAndDismiss() } diff --git a/Strand/Screens/LiveWorkoutView.swift b/Strand/Screens/LiveWorkoutView.swift index a57a3364d3..4a402d6b09 100644 --- a/Strand/Screens/LiveWorkoutView.swift +++ b/Strand/Screens/LiveWorkoutView.swift @@ -38,6 +38,7 @@ struct LiveWorkoutView: View { /// control must not end the workout instantly with no way back. @State private var showEndConfirm = false @State private var showDeleteConfirm = false + @State private var showSaveError = false private var zoneSet: HRZoneSet { model.profile.hrZoneSet } private var zone: Int { model.bpm.map { zoneSet.zoneNumber(forBPM: Double($0)) } ?? 0 } @@ -112,12 +113,17 @@ struct LiveWorkoutView: View { isPresented: $showEndConfirm) { Button("Cancel", role: .cancel) { } Button("End", role: .destructive) { - model.endWorkout() - onClose() + Task { + do { try await model.finishWorkout(); onClose() } + catch { showSaveError = true } + } } } message: { Text("This stops recording and saves what's captured so far. It can't be resumed.") } + .alert("Training could not be saved", isPresented: $showSaveError) { + Button("OK", role: .cancel) {} + } message: { Text("Your paused training is still available. Please try again.") } .confirmationDialog("Delete", isPresented: $showDeleteConfirm, titleVisibility: .visible) { Button("Delete", role: .destructive) { diff --git a/Strand/Screens/TestCentreView.swift b/Strand/Screens/TestCentreView.swift index c5adbb48b6..aefb4283f0 100644 --- a/Strand/Screens/TestCentreView.swift +++ b/Strand/Screens/TestCentreView.swift @@ -894,6 +894,9 @@ private struct TestModeRow: View { @ObservedObject var report: TestCentreReport @EnvironmentObject var live: LiveState @EnvironmentObject var model: AppModel + #if os(iOS) + @EnvironmentObject var liftSession: LiftSessionController + #endif @State private var on: Bool = false private var elapsed: Double? { @@ -933,9 +936,11 @@ private struct TestModeRow: View { // Display & Performance owns a live frame monitor. It must run ONLY while the mode // is on: start it on toggle-on (after wiring its sink to the redacting .display // log), tear it down on toggle-off so no display link survives. Zero-cost when off. + #if os(macOS) if mode.domain == .display { if isOn { startDisplayMonitor() } else { DisplayPerformanceMonitor.shared.stop() } } + #endif } } Text(mode.blurb) @@ -984,29 +989,32 @@ private struct TestModeRow: View { on = TestCentre.active(mode.domain) // If the Display mode was already on when the screen appears, (re)start its frame monitor and // wire the sink, so a monitor that was torn down (e.g. the screen left and came back) resumes. + #if os(macOS) if mode.domain == .display, on { startDisplayMonitor() } + #endif } .onDisappear { // Leaving the screen tears the frame monitor down so no display link survives a navigation // away. The mode flag stays on (the user's test is still active); the monitor resumes on // .onAppear above. This keeps the perpetual-display-link contract: a link exists only while the // Test Centre is on screen with the mode on. + #if os(macOS) if mode.domain == .display { DisplayPerformanceMonitor.shared.stop() } + #endif } } /// Wire the Display monitor's sink to the redacting `.display` log and start it. The sink is set every /// start so a fresh LiveState (e.g. after a screen re-entry) is always the live target. private func startDisplayMonitor() { - DisplayPerformanceMonitor.shared.emit = { [weak live] line in - live?.append(log: line, domain: .display) - } // CAPTURE-D (#797): wire the data-volume provider so start() emits one `dataVolume` line read STRAIGHT // from the store (Repository.dataVolumeSnapshot queries the store, not the @Published caches), so an // import-driven-lag report shows the read-set behind the frame stats. - DisplayPerformanceMonitor.shared.dataVolumeProvider = { [weak model] in - await model?.repo.dataVolumeSnapshot() - } + #if os(iOS) + DisplayPerformanceMonitor.shared.configure(model: model, lift: liftSession) + #else + DisplayPerformanceMonitor.shared.configure(model: model) + #endif DisplayPerformanceMonitor.shared.start() } } diff --git a/Strand/System/DisplayPerformanceMonitor.swift b/Strand/System/DisplayPerformanceMonitor.swift index 267f8de360..a6f4982fdb 100644 --- a/Strand/System/DisplayPerformanceMonitor.swift +++ b/Strand/System/DisplayPerformanceMonitor.swift @@ -1,4 +1,5 @@ import Foundation +import Combine import StrandAnalytics #if os(iOS) import UIKit @@ -23,7 +24,7 @@ import CoreVideo // readers live here too so the whole Display capture surface is one file. /// Owns the live frame monitor for the Display mode. A single shared instance is started / stopped from -/// the Test Centre toggle. `@MainActor` because it touches the display link, the key window, and the +/// the Test Centre toggle and app lifecycle. `@MainActor` because it touches the display link, the key window, and the /// LiveState sink, all of which are main-thread concerns. @MainActor final class DisplayPerformanceMonitor { @@ -35,9 +36,9 @@ final class DisplayPerformanceMonitor { /// dropped frame at 60 Hz / a slow frame at 120 Hz, a sensible "the user felt that" threshold. static let hitchThresholdMs: Double = 33 - /// Emit one rolling summary per this many frames (~1 s at 60 Hz). Per-window, never per-frame, so the - /// emission is a natural throttle and the strap log never floods. - static let windowFrames = 60 + /// Bound each window to 600 samples and normally emit every five seconds of foreground use. + static let windowFrames = 600 + static let summaryIntervalMs: Double = 5_000 /// The sink that writes a tagged `.display` line. Set by the screen to LiveState.append(log:domain:); /// nil leaves the monitor inert (e.g. before the screen wired it, or in a test with no LiveState). @@ -50,6 +51,36 @@ final class DisplayPerformanceMonitor { var dataVolumeProvider: (() async -> DataVolume?)? private var running = false + private var foregroundActive = false + enum Tab: String { case unknown, today, trends, sleep, coach, more } + private var tab: Tab = .unknown + enum TrainingPhase: String { + case none, active, paused + + init(paused: Bool?) { + self = paused.map { $0 ? .paused : .active } ?? .none + } + } + private var workoutPhase: TrainingPhase = .none + private var liftPhase: TrainingPhase = .none + private weak var model: AppModel? + private weak var lift: LiftSessionController? + private var trainingObservers: [AnyCancellable] = [] + private var dataVolumeTask: Task? + private var lastExpectedInterval: CFTimeInterval = 0 + private var windowElapsedMs: Double = 0 + private var windowExpectedMs: Double = 0 + private var windowLateCallbacks = 0 + private var lastCPUSeconds: Double? + private var lastCPUUptime: Double = 0 + private var currentMemoryMB: Double? + private lazy var performanceArchive: StrapLogArchive = { + let directory = LiveState.archive.directory.appendingPathComponent("performance", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return StrapLogArchive(directory: directory) + }() + private var captureStartedUptime: Double = 0 + private var syncActiveProvider: (() -> Bool)? // Frame-time accumulation for the current window. private var lastFrameTimestamp: CFTimeInterval = 0 @@ -70,6 +101,57 @@ final class DisplayPerformanceMonitor { /// True only while a display link is live. The test asserts this is false when the mode is off. var isRunning: Bool { running } + func configure(model: AppModel, lift: LiftSessionController? = nil) { + let restart = running + stop() + self.model = model + self.lift = lift + let live = model.live + let repo = model.repo + emit = { [weak self, weak live] line in + live?.append(log: line, domain: .display) + // Keep long-running performance captures when verbose strap sync rotates the shared log. + self?.performanceArchive.append(LiveState.redactPii("[display] " + line)) + } + dataVolumeProvider = { [weak repo] in await repo?.dataVolumeSnapshot() } + syncActiveProvider = { [weak live] in live?.backfilling == true || live?.historyPendingSync == true } + if restart { start() } + } + + var performanceLogText: String { performanceArchive.exportText() } + + func setForegroundActive(_ active: Bool) { + foregroundActive = active + refreshActivation() + } + + func refreshActivation() { + if foregroundActive && TestCentre.active(.display) && emit != nil { start() } + else { stop() } + } + + func setTab(_ value: Tab) { + guard value != tab else { return } + flushWindow() + tab = value + } + + private var trainingContext: String { + "workout=\(model == nil ? "n/a" : workoutPhase.rawValue) lift=\(lift == nil ? "n/a" : liftPhase.rawValue)" + } + + private func setTraining(workout: TrainingPhase, lift: TrainingPhase) { + guard workout != workoutPhase || lift != liftPhase else { return } + flushWindow() + workoutPhase = workout + liftPhase = lift + if running { + emit?(String(format: "trainingPhase elapsed=%.1fs tab=%@ %@", + ProcessInfo.processInfo.systemUptime - captureStartedUptime, + tab.rawValue, trainingContext)) + } + } + // MARK: - Lifecycle (the ONLY place a display link is created / destroyed) /// Start the monitor: create the platform display link and begin sampling. Idempotent (a second @@ -80,8 +162,34 @@ final class DisplayPerformanceMonitor { running = true resetWindow() lastFrameTimestamp = 0 + lastExpectedInterval = 0 + lastCPUSeconds = Self.cpuSeconds() + lastCPUUptime = ProcessInfo.processInfo.systemUptime + captureStartedUptime = lastCPUUptime memoryPeakMB = 0 + currentMemoryMB = nil sampleMemory() + workoutPhase = TrainingPhase(paused: model?.activeWorkout?.isPaused) + liftPhase = TrainingPhase(paused: lift?.engine.flatMap { $0.isFinished ? nil : $0.isPaused }) + emit?("captureStart version=\(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown") build=\(Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown") \(trainingContext)") + if let model { + trainingObservers.append(model.$activeWorkout + .map { TrainingPhase(paused: $0?.isPaused) } + .removeDuplicates() + .sink { [weak self] phase in + guard let self else { return } + self.setTraining(workout: phase, lift: self.liftPhase) + }) + } + if let lift { + trainingObservers.append(lift.$engine + .map { TrainingPhase(paused: $0.flatMap { $0.isFinished ? nil : $0.isPaused }) } + .removeDuplicates() + .sink { [weak self] phase in + guard let self else { return } + self.setTraining(workout: self.workoutPhase, lift: phase) + }) + } emitDeviceMetrics() emitDataVolume() @@ -96,9 +204,12 @@ final class DisplayPerformanceMonitor { if let link { // The CVDisplayLink callback fires on a private CV thread; hop to the main actor to fold the // sample in, so all monitor state stays main-isolated like the iOS path. - CVDisplayLinkSetOutputHandler(link) { [weak self] _, inNow, _, _, _ in + CVDisplayLinkSetOutputHandler(link) { [weak self] _, inNow, outputTime, _, _ in let nowSeconds = Double(inNow.pointee.hostTime) / Double(machTimebaseHz()) - Task { @MainActor in self?.foldFrame(timestamp: nowSeconds) } + let output = outputTime.pointee + let interval = output.videoTimeScale > 0 + ? Double(output.videoRefreshPeriod) / Double(output.videoTimeScale) : 0 + Task { @MainActor in self?.foldFrame(timestamp: nowSeconds, expectedInterval: interval) } return kCVReturnSuccess } CVDisplayLinkStart(link) @@ -113,6 +224,9 @@ final class DisplayPerformanceMonitor { func stop() { guard running else { return } running = false + trainingObservers.removeAll() + dataVolumeTask?.cancel() + dataVolumeTask = nil #if os(iOS) displayLink?.invalidate() @@ -134,24 +248,31 @@ final class DisplayPerformanceMonitor { #if os(iOS) @objc private func onFrame(_ link: CADisplayLink) { - foldFrame(timestamp: link.timestamp) + foldFrame(timestamp: link.timestamp, expectedInterval: link.targetTimestamp - link.timestamp) } #endif /// Fold one frame timestamp into the current window. The first frame seeds the previous-timestamp and /// is not counted (no duration yet). Once a window fills (`windowFrames`), it flushes a summary and /// resets. Pure arithmetic plus the periodic emit; no allocation per frame beyond the bounded buffer. - private func foldFrame(timestamp: CFTimeInterval) { + func foldFrame(timestamp: CFTimeInterval, expectedInterval: CFTimeInterval) { guard running else { return } - defer { lastFrameTimestamp = timestamp } + defer { + lastFrameTimestamp = timestamp + lastExpectedInterval = expectedInterval + } guard lastFrameTimestamp > 0 else { return } // seed frame, no duration yet let durationMs = (timestamp - lastFrameTimestamp) * 1000.0 guard durationMs > 0, durationMs < 5_000 else { return } // ignore a backgrounding gap windowDurationsMs.append(durationMs) + windowElapsedMs += durationMs + // Use the slower adjacent cadence so an adaptive refresh-rate change is not counted as a stall. + let expectedMs = max(expectedInterval, lastExpectedInterval) * 1000 + windowExpectedMs += expectedMs + if Self.callbackIsLate(durationMs: durationMs, expectedMs: expectedMs) { windowLateCallbacks += 1 } if durationMs > Self.hitchThresholdMs { windowHitches += 1 } if durationMs > windowWorstMs { windowWorstMs = durationMs } - if windowDurationsMs.count >= Self.windowFrames { - sampleMemory() + if windowElapsedMs >= Self.summaryIntervalMs || windowDurationsMs.count >= Self.windowFrames { flushWindow() } } @@ -160,12 +281,29 @@ final class DisplayPerformanceMonitor { /// window is empty (nothing to summarise), so a stop() with no frames does not emit a zero line. private func flushWindow() { guard !windowDurationsMs.isEmpty else { return } + sampleMemory() let stats = DisplayPerformanceMonitor.windowStats(durationsMs: windowDurationsMs) + let now = ProcessInfo.processInfo.systemUptime + let cpu = Self.cpuSeconds() + let cpuLabel: String + if let cpu, let previousCPU = lastCPUSeconds, now > lastCPUUptime { + cpuLabel = String(format: "%.1f%%", max(0, cpu - previousCPU) / (now - lastCPUUptime) * 100) + } else { cpuLabel = "n/a" } + lastCPUSeconds = cpu + lastCPUUptime = now emit?(DisplayTrace.frameSummaryLine( frames: windowDurationsMs.count, meanMs: stats.mean, p95Ms: stats.p95, hitches: windowHitches, worstMs: windowWorstMs, - hitchThresholdMs: Self.hitchThresholdMs)) + hitchThresholdMs: Self.hitchThresholdMs) + + String(format: " elapsed=%.1fs tab=%@ cadence=%.1fms lateCallbacks=%d cpu=%@ memory=%@ thermal=%d lowPower=%@ sync=%@", + now - captureStartedUptime, tab.rawValue, + windowExpectedMs / Double(windowDurationsMs.count), windowLateCallbacks, + cpuLabel, currentMemoryMB.map { String(format: "%.1fMB", $0) } ?? "n/a", + ProcessInfo.processInfo.thermalState.rawValue, + ProcessInfo.processInfo.isLowPowerModeEnabled ? "true" : "false", + syncActiveProvider.map { $0() ? "true" : "false" } ?? "n/a") + + " " + trainingContext) resetWindow() } @@ -173,6 +311,21 @@ final class DisplayPerformanceMonitor { windowDurationsMs.removeAll(keepingCapacity: true) windowHitches = 0 windowWorstMs = 0 + windowElapsedMs = 0 + windowExpectedMs = 0 + windowLateCallbacks = 0 + } + + // ponytail: callback timing cannot identify GPU/render stalls; use Instruments for attribution. + static func callbackIsLate(durationMs: Double, expectedMs: Double) -> Bool { + expectedMs > 0 && durationMs > expectedMs * 1.5 + } + + private static func cpuSeconds() -> Double? { + var usage = rusage() + guard getrusage(RUSAGE_SELF, &usage) == 0 else { return nil } + return Double(usage.ru_utime.tv_sec + usage.ru_stime.tv_sec) + + Double(usage.ru_utime.tv_usec + usage.ru_stime.tv_usec) / 1_000_000 } /// Pure window stats: mean and p95 of the frame durations. Static and side-effect-free so a fixture @@ -181,12 +334,8 @@ final class DisplayPerformanceMonitor { guard !durationsMs.isEmpty else { return (0, 0) } let mean = durationsMs.reduce(0, +) / Double(durationsMs.count) let sorted = durationsMs.sorted() - // Nearest-rank p95 as a 0-based index: idx = ceil(0.95 * n), clamped to the last element. This - // counts the values that fall BELOW the percentile, so at least 95% of frames are at or below the - // returned value. For a hitch trace that matters: a window of 20 frames with one 5%-tail hitch - // lands p95 ON the hitch (idx = ceil(0.95 * 20) = 19 = the worst of 20), surfacing exactly the - // slow frame the user is reporting rather than hiding it under the 95% of healthy frames. - let idx = Int((0.95 * Double(sorted.count)).rounded(.up)) + // Nearest rank is one-based; subtract one before indexing the sorted samples. + let idx = Int((0.95 * Double(sorted.count)).rounded(.up)) - 1 let p95 = sorted[min(idx, sorted.count - 1)] return (mean, p95) } @@ -197,7 +346,10 @@ final class DisplayPerformanceMonitor { /// task_info (the same number Xcode's memory gauge shows); a read failure leaves the mark unchanged /// rather than fabricating a value. private func sampleMemory() { - if let mb = Self.residentFootprintMB(), mb > memoryPeakMB { memoryPeakMB = mb } + if let mb = Self.residentFootprintMB() { + currentMemoryMB = mb + memoryPeakMB = max(memoryPeakMB, mb) + } } /// Current resident footprint in MB via `task_info(TASK_VM_INFO).phys_footprint`, or nil on a read @@ -229,10 +381,10 @@ final class DisplayPerformanceMonitor { /// line, exactly as before. Side-effect-only; never touches the frame monitor. private func emitDataVolume() { guard let dataVolumeProvider, emit != nil else { return } - Task { [weak self] in + dataVolumeTask = Task { [weak self] in let volume = await dataVolumeProvider() await MainActor.run { - guard let self, let volume else { return } + guard let self, self.running, !Task.isCancelled, let volume else { return } self.emit?(DisplayTrace.dataVolumeLine(volume)) } } diff --git a/Strand/System/TestBundleAssembler.swift b/Strand/System/TestBundleAssembler.swift index 43d342aef7..ad02e02a23 100644 --- a/Strand/System/TestBundleAssembler.swift +++ b/Strand/System/TestBundleAssembler.swift @@ -273,7 +273,11 @@ enum TestBundleAssembler { // capEntries budgets raw-capture as capBytes - (everything else), so a large/retina PNG shrinks // the raw-capture tail rather than breaching the cap. Only raw-capture is trimmed; report.txt and // last-crash are bounded and the PNG is kept whole. - let textEntries = [reportEntry] + (rawCapture.map { [$0] } ?? []) + (crash.map { [$0] } ?? []) + ouraDiagnostics + let performanceText = (profile == .display || profile == .master) + ? DisplayPerformanceMonitor.shared.performanceLogText : "" + let performance = performanceText.isEmpty ? [] + : [FileExport.BundleEntry(name: "performance.txt", data: Data(performanceText.utf8))] + let textEntries = [reportEntry] + performance + (rawCapture.map { [$0] } ?? []) + (crash.map { [$0] } ?? []) + ouraDiagnostics let redacted = redactEntries(textEntries) let (capped, truncated) = capEntries(redacted + (shot.map { [$0] } ?? [])) var entries = capped diff --git a/Strand/System/TestCentre.swift b/Strand/System/TestCentre.swift index 12f3e9dbb2..544a557142 100644 --- a/Strand/System/TestCentre.swift +++ b/Strand/System/TestCentre.swift @@ -36,10 +36,16 @@ public enum TestCentre { @MainActor public static func activate(_ d: TestDomain) { UserDefaults.standard.set(true, forKey: activePrefix + d.id) UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: startedPrefix + d.id) + #if os(iOS) + if d == .display || d == .master { DisplayPerformanceMonitor.shared.refreshActivation() } + #endif } @MainActor public static func deactivate(_ d: TestDomain) { UserDefaults.standard.set(false, forKey: activePrefix + d.id) + #if os(iOS) + if d == .display || d == .master { DisplayPerformanceMonitor.shared.refreshActivation() } + #endif } public static func startedAt(_ d: TestDomain) -> Date? { diff --git a/StrandTests/ActiveWorkoutPersistenceTests.swift b/StrandTests/ActiveWorkoutPersistenceTests.swift index c661c74366..090910dd6a 100644 --- a/StrandTests/ActiveWorkoutPersistenceTests.swift +++ b/StrandTests/ActiveWorkoutPersistenceTests.swift @@ -36,7 +36,9 @@ final class ActiveWorkoutPersistenceTests: XCTestCase { // MARK: - pure codec round-trip func testEncodeDecodeRoundTripsEveryField() { - let original = snapshot(pausedAtSec: 1_700_000_120, pausedDurationSec: 45) + var original = snapshot(pausedAtSec: 1_700_000_120, pausedDurationSec: 45) + original.sessionID = "stable-widget-session" + original.pausedForPulseLoss = true let decoded = ActiveWorkoutPersistence.decode(ActiveWorkoutPersistence.encode(original)) XCTAssertEqual(decoded, original) } diff --git a/StrandTests/DisplayPerformanceTests.swift b/StrandTests/DisplayPerformanceTests.swift index ddabe6991e..edc2a998b4 100644 --- a/StrandTests/DisplayPerformanceTests.swift +++ b/StrandTests/DisplayPerformanceTests.swift @@ -14,11 +14,14 @@ final class DisplayPerformanceTests: XCTestCase { UserDefaults.standard.removeObject(forKey: "testcentre.active.display") UserDefaults.standard.removeObject(forKey: "testcentre.active.master") DisplayPerformanceMonitor.shared.stop() + DisplayPerformanceMonitor.shared.setForegroundActive(false) + DisplayPerformanceMonitor.shared.setTab(.unknown) DisplayPerformanceMonitor.shared.emit = nil } override func tearDown() { DisplayPerformanceMonitor.shared.stop() + DisplayPerformanceMonitor.shared.setForegroundActive(false) DisplayPerformanceMonitor.shared.emit = nil DisplayPerformanceMonitor.shared.dataVolumeProvider = nil UserDefaults.standard.removeObject(forKey: "testcentre.active.display") @@ -61,6 +64,7 @@ final class DisplayPerformanceTests: XCTestCase { DisplayPerformanceMonitor.shared.emit = { captured.append($0) } DisplayPerformanceMonitor.shared.start() XCTAssertTrue(DisplayPerformanceMonitor.shared.isRunning) + XCTAssertEqual(captured.filter { $0.hasPrefix("captureStart ") }.count, 1) XCTAssertTrue(captured.contains { $0.hasPrefix("deviceMetrics ") }, "start() must emit one device-metrics line, got \(captured)") DisplayPerformanceMonitor.shared.stop() @@ -110,14 +114,177 @@ final class DisplayPerformanceTests: XCTestCase { DisplayPerformanceMonitor.shared.stop() } + func testForegroundCaptureAcrossTabsAndBackground() { + let monitor = DisplayPerformanceMonitor.shared + var captured: [String] = [] + monitor.emit = { captured.append($0) } + monitor.setForegroundActive(true) + XCTAssertFalse(monitor.isRunning) + XCTAssertTrue(captured.isEmpty) + + TestCentre.activate(.display) + monitor.refreshActivation() + XCTAssertTrue(monitor.isRunning) + monitor.setTab(.today) + monitor.foldFrame(timestamp: 100, expectedInterval: 1.0 / 120) + monitor.foldFrame(timestamp: 100 + 1.0 / 60, expectedInterval: 1.0 / 120) + monitor.setTab(.trends) + XCTAssertTrue(monitor.isRunning) + XCTAssertTrue(captured.contains { $0.contains("tab=today") && $0.contains("lateCallbacks=1") }) + monitor.foldFrame(timestamp: 100 + 2.0 / 60, expectedInterval: 1.0 / 120) + monitor.setForegroundActive(false) + XCTAssertFalse(monitor.isRunning) + XCTAssertTrue(captured.contains { $0.contains("tab=trends") }) + + monitor.setForegroundActive(true) + XCTAssertTrue(monitor.isRunning) + TestCentre.deactivate(.display) + monitor.refreshActivation() + XCTAssertFalse(monitor.isRunning) + } + + func testLateCallbacksFollowActualCadence() { + XCTAssertFalse(DisplayPerformanceMonitor.callbackIsLate(durationMs: 8.4, expectedMs: 8.33)) + XCTAssertTrue(DisplayPerformanceMonitor.callbackIsLate(durationMs: 16.67, expectedMs: 8.33)) + XCTAssertFalse(DisplayPerformanceMonitor.callbackIsLate(durationMs: 16.67, expectedMs: 16.67)) + XCTAssertTrue(DisplayPerformanceMonitor.callbackIsLate(durationMs: 33.34, expectedMs: 16.67)) + XCTAssertFalse(DisplayPerformanceMonitor.callbackIsLate(durationMs: 33.34, expectedMs: 33.34)) + XCTAssertFalse(DisplayPerformanceMonitor.callbackIsLate(durationMs: 33.34, expectedMs: 0)) + let monitor = DisplayPerformanceMonitor.shared + var captured: [String] = [] + monitor.emit = { captured.append($0) } + monitor.start() + monitor.foldFrame(timestamp: 100, expectedInterval: 1.0 / 60) + monitor.foldFrame(timestamp: 100 + 1.0 / 60, expectedInterval: 1.0 / 120) + monitor.stop() + XCTAssertTrue(captured.contains { $0.contains("lateCallbacks=0") }) + } + + func testWorkoutPhasesSplitWindowsAcrossTodayAndTrends() { + withTrainingSources { model, _, monitor in + var captured: [String] = [] + monitor.emit = { captured.append($0) } + monitor.setTab(.today) + monitor.start() + monitor.foldFrame(timestamp: 100, expectedInterval: 1.0 / 60) + monitor.foldFrame(timestamp: 100.02, expectedInterval: 1.0 / 60) + model.activeWorkout = AppModel.ActiveWorkout(start: Date(timeIntervalSince1970: 100)) + monitor.foldFrame(timestamp: 100.04, expectedInterval: 1.0 / 60) + model.activeWorkout?.avgHr = 150 + XCTAssertEqual(captured.filter { $0.hasPrefix("trainingPhase ") }.count, 1) + monitor.setTab(.trends) + monitor.foldFrame(timestamp: 100.06, expectedInterval: 1.0 / 60) + model.pauseWorkout(at: Date(timeIntervalSince1970: 101)) + monitor.foldFrame(timestamp: 100.08, expectedInterval: 1.0 / 60) + model.resumeWorkout(at: Date(timeIntervalSince1970: 102)) + monitor.foldFrame(timestamp: 100.10, expectedInterval: 1.0 / 60) + model.activeWorkout = nil + monitor.foldFrame(timestamp: 100.12, expectedInterval: 1.0 / 60) + monitor.stop() + + let windows = captured.filter { $0.hasPrefix("frameSummary ") } + XCTAssertEqual(windows.count, 6) + for (line, expected) in zip(windows, ["none", "active", "active", "paused", "active", "none"]) { + XCTAssertTrue(line.contains("workout=\(expected) lift=none"), line) + } + XCTAssertTrue(windows[0].contains("tab=today")) + XCTAssertTrue(windows[1].contains("tab=today")) + XCTAssertTrue(windows.dropFirst(2).allSatisfy { $0.contains("tab=trends") }) + XCTAssertEqual(captured.filter { $0.hasPrefix("trainingPhase ") }.count, 4) + } + } + + func testLiftPhasesAndForegroundRestartReadTheCurrentSession() { + withTrainingSources { model, lift, monitor in + var captured: [String] = [] + monitor.emit = { captured.append($0) } + model.activeWorkout = AppModel.ActiveWorkout(start: Date()) + lift.start(plan: [.init(exercise: "Row")], programId: nil, programName: nil) + lift.pause() + XCTAssertTrue(captured.isEmpty, "training changes emit nothing while capture is stopped") + monitor.start() + XCTAssertTrue(captured.first?.hasSuffix("workout=active lift=paused") == true) + monitor.foldFrame(timestamp: 100, expectedInterval: 1.0 / 60) + monitor.foldFrame(timestamp: 100.02, expectedInterval: 1.0 / 60) + lift.resume() + XCTAssertTrue(captured.contains { $0.hasPrefix("frameSummary ") && $0.hasSuffix("workout=active lift=paused") }) + monitor.foldFrame(timestamp: 100.22, expectedInterval: 1.0 / 60) + lift.finish() + XCTAssertTrue(captured.contains { $0.hasPrefix("frameSummary ") && $0.contains("hitches=1") && $0.hasSuffix("workout=active lift=active") }) + XCTAssertTrue(captured.last?.hasSuffix("workout=active lift=none") == true) + monitor.stop() + + let stoppedCount = captured.count + model.pauseWorkout() + lift.discard() + lift.start(plan: [.init(exercise: "Row")], programId: nil, programName: nil) + monitor.foldFrame(timestamp: 101, expectedInterval: 1.0 / 60) + XCTAssertEqual(captured.count, stoppedCount) + monitor.start() + XCTAssertTrue(captured.contains { $0.hasPrefix("captureStart ") && $0.hasSuffix("workout=paused lift=active") }) + monitor.foldFrame(timestamp: 200, expectedInterval: 1.0 / 60) + monitor.foldFrame(timestamp: 200.02, expectedInterval: 1.0 / 60) + monitor.stop() + XCTAssertTrue(captured.contains { $0.hasPrefix("frameSummary ") && $0.hasSuffix("workout=paused lift=active") }) + } + } + + func testMissingLiftSourceDoesNotClaimThereIsNoSession() { + withTrainingSources { model, _, monitor in + monitor.configure(model: model) + var captured: [String] = [] + monitor.emit = { captured.append($0) } + monitor.start() + XCTAssertTrue(captured.first?.hasSuffix("workout=none lift=n/a") == true) + } + } + + private func withTrainingSources(_ check: (AppModel, LiftSessionController, DisplayPerformanceMonitor) -> Void) { + let defaults = UserDefaults.standard + let keys = [ActiveWorkoutPersistence.defaultsKey, LiftSessionPersistence.defaultsKey] + let saved = keys.map { defaults.object(forKey: $0) } + let previousModel = AppModel.shared + let model = AppModel() + model.activeWorkout = nil + let lift = LiftSessionController(buzz: { _ in }, setStrapHandler: { _ in }) + let monitor = DisplayPerformanceMonitor.shared + monitor.configure(model: model, lift: lift) + defer { + monitor.stop() + lift.discard() + AppModel.shared = previousModel + for (key, value) in zip(keys, saved) { + if let value { defaults.set(value, forKey: key) } + else { defaults.removeObject(forKey: key) } + } + } + check(model, lift, monitor) + } + + func testStoppedCaptureDoesNotEmitDelayedDataVolume() async { + let monitor = DisplayPerformanceMonitor.shared + let captured = LineBox() + monitor.emit = { captured.append($0) } + monitor.dataVolumeProvider = { + try? await Task.sleep(nanoseconds: 10_000_000) + return DataVolume(dbRows: 1, importedDays: 1, workouts: 1, lastRenderRows: nil) + } + monitor.start() + await Task.yield() + monitor.stop() + try? await Task.sleep(nanoseconds: 30_000_000) + XCTAssertFalse(captured.lines.contains { $0.hasPrefix("dataVolume ") }) + } + // MARK: - Pure window stats (no display link needed). func testWindowStatsMeanAndP95() { - // 19 frames at 16 ms and one 100 ms hitch: mean is pulled up a little, p95 picks the hitch. + // 19 frames at 16 ms and one 100 ms hitch: the first 95% remain at 16 ms. let durations = Array(repeating: 16.0, count: 19) + [100.0] let stats = DisplayPerformanceMonitor.windowStats(durationsMs: durations) XCTAssertEqual(stats.mean, (16.0 * 19 + 100.0) / 20.0, accuracy: 0.001) - XCTAssertEqual(stats.p95, 100.0, accuracy: 0.001, "p95 of 20 frames is the worst (nearest-rank)") + XCTAssertEqual(stats.p95, 16.0, accuracy: 0.001) + XCTAssertEqual(DisplayPerformanceMonitor.windowStats(durationsMs: [42]).p95, 42) } func testWindowStatsEmptyIsZero() { diff --git a/StrandTests/LiftSessionHistoryLoadingTests.swift b/StrandTests/LiftSessionHistoryLoadingTests.swift new file mode 100644 index 0000000000..c9823bd444 --- /dev/null +++ b/StrandTests/LiftSessionHistoryLoadingTests.swift @@ -0,0 +1,47 @@ +import XCTest +import WhoopStore +@testable import Strand + +@MainActor +final class LiftSessionHistoryLoadingTests: XCTestCase { + func testAddingAnExerciseLoadsItsPreviousSets() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = try await WhoopStore(path: directory.appendingPathComponent("history.sqlite").path) + let repo = Repository(deviceId: "history-test", store: store) + let controller = LiftSessionController(buzz: { _ in }, setStrapHandler: { _ in }) + defer { controller.discard() } + let start = Int(Date().timeIntervalSince1970) - 3_600 + _ = try await store.upsertLiftSessions([ + .init(id: "previous", deviceId: repo.deviceId, startTs: start, endTs: start + 600, + sport: "Strength", programId: nil, programName: nil, sessionRpe: nil, note: nil) + ]) + _ = try await store.upsertLiftSets([ + .init(id: "previous-fly", deviceId: repo.deviceId, sessionId: "previous", ord: 0, + exercise: "Cable fly", primaryMuscle: .chest, setIndex: 1, weightKg: 15, reps: 12, + rpe: nil, isWarmup: false, startTs: start, endTs: start + 30, restSec: 60, note: nil) + ]) + controller.start(plan: [.init(exercise: "Bench press")], programId: nil, programName: nil) + await controller.loadLastSession(repo: repo) + XCTAssertTrue(controller.addExercise("Cable fly", primaryMuscle: .chest, secondaryMuscles: [])) + await controller.loadLastSession(repo: repo) + XCTAssertEqual(controller.carry(for: .init(exerciseIndex: 1, setIndex: 1)), + LiftSetCarry(weightKg: 15, reps: 12)) + } + + func testStartingWhileActivePresentsTheExistingSessionOnlyWhenRequested() async throws { + let controller = LiftSessionController(buzz: { _ in }, setStrapHandler: { _ in }) + defer { controller.discard() } + controller.start(plan: [.init(exercise: "Row")], programId: nil, programName: nil) + let id = controller.sessionID + let program = LiftProgramRow(id: "other", deviceId: "test", name: "Other", note: nil, + createdAt: 1, updatedAt: 1, archived: false) + controller.isPresented = false + try await controller.start(program: program, repo: Repository(deviceId: "test"), present: false) + XCTAssertFalse(controller.isPresented) + try await controller.start(program: program, repo: Repository(deviceId: "test")) + XCTAssertTrue(controller.isPresented) + XCTAssertEqual(controller.sessionID, id) + } +} diff --git a/StrandTests/LiftSessionSaveFailureTests.swift b/StrandTests/LiftSessionSaveFailureTests.swift new file mode 100644 index 0000000000..9bf2fb5035 --- /dev/null +++ b/StrandTests/LiftSessionSaveFailureTests.swift @@ -0,0 +1,60 @@ +import XCTest +import WhoopStore +@testable import Strand + +@MainActor +final class LiftSessionSaveFailureTests: XCTestCase { + func testFailedWriteRetainsSessionAndRetryDoesNotDuplicateItsRows() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let path = directory.appendingPathComponent("training.sqlite").path + let store = try await WhoopStore(path: path) + let repo = Repository(deviceId: "training-test", store: store) + let controller = LiftSessionController(buzz: { _ in }, setStrapHandler: { _ in }) + defer { + controller.discard() + } + controller.start(plan: [.init(exercise: "Row", targetSets: 2, restSec: 90)], programId: nil, programName: nil) + controller.advance() + controller.advance() + controller.sessionRpeText = "7" + let id = controller.sessionID + let start = try XCTUnwrap(controller.engine?.startTs) + try execute("CREATE TRIGGER fail_training_save BEFORE INSERT ON workout BEGIN SELECT RAISE(FAIL, 'test disk error'); END", path: path) + do { + _ = try await controller.save(repo: repo, completingUnfinished: false, sessionRpe: nil) + XCTFail("the database rejected the workout write") + } catch {} + XCTAssertTrue(controller.isActive) + XCTAssertTrue(controller.engine?.isPaused == true) + XCTAssertEqual(LiftSessionPersistence.load()?.sessionID, id) + let partiallySaved = try await store.liftSets(sessionId: id) + XCTAssertEqual(partiallySaved.count, 2) + XCTAssertEqual(partiallySaved.filter { $0.startTs != nil }.count, 1) + XCTAssertTrue(controller.removeSet(fromExercise: 0)) + try execute("DROP TRIGGER fail_training_save", path: path) + _ = try await controller.save(repo: repo, completingUnfinished: false, sessionRpe: nil) + _ = try await controller.save(repo: repo, completingUnfinished: false, sessionRpe: nil) + let sessions = try await store.liftSessions(deviceId: "training-test", fromTs: start, toTs: start) + XCTAssertEqual(sessions.count, 1) + XCTAssertEqual(sessions.first?.id, id) + XCTAssertEqual(sessions.first?.sessionRpe, 7) + let sets = try await store.liftSets(sessionId: id) + XCTAssertEqual(sets.count, 1, "a pending set removed after a failed save must not remain in storage") + XCTAssertEqual(sets.filter { $0.startTs != nil }.count, 1, "an open set is never invented as completed") + controller.finishedSaving() + XCTAssertNil(controller.engine) + XCTAssertNil(LiftSessionPersistence.load()) + } + // Use macOS's SQLite CLI: GRDB is linked in the host app, not directly in the test bundle. + private func execute(_ sql: String, path: String) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3") + process.arguments = [path, sql] + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { throw CocoaError(.fileWriteUnknown) } + } + +} diff --git a/StrandTests/LiftSessionTimingTests.swift b/StrandTests/LiftSessionTimingTests.swift index 192fde3831..437a24b544 100644 --- a/StrandTests/LiftSessionTimingTests.swift +++ b/StrandTests/LiftSessionTimingTests.swift @@ -40,14 +40,29 @@ final class LiftSessionTimingTests: XCTestCase { c.start(plan: plan(restSec: 2), programId: "p", programName: "Upper A") c.advance() // set 1 working c.advance() // set 1 done: a 2 s rest - XCTAssertEqual(c.presentation(system: .metric)?.status, "Resting after set 1") + let setIndex = 1 + XCTAssertEqual(c.presentation(system: .metric)?.status, String(localized: "Resting after set \(setIndex)")) var changes = 0 let watching = c.objectWillChange.sink { changes += 1 } RunLoop.main.run(until: Date().addingTimeInterval(3.2)) watching.cancel() XCTAssertEqual(changes, 1, "the rest's end, once") XCTAssertEqual(buzzes, [LiftSessionController.restWarningBuzzes], "the warning, once") - XCTAssertEqual(c.presentation(system: .metric)?.status, "Ready for the next set") + XCTAssertEqual(c.presentation(system: .metric)?.status, String(localized: "Ready for the next set")) + } + + func testPauseCancelsRestWarningAndPreservesTheRemainingTime() { + var buzzes: [UInt8] = [] + let controller = LiftSessionController(buzz: { buzzes.append($0) }, setStrapHandler: { _ in }) + controller.start(plan: plan(restSec: 2), programId: nil, programName: nil) + controller.advance() + controller.advance() + controller.pause() + let remaining = controller.engine?.restRemaining(now: LiftSessionController.unixNow) + RunLoop.main.run(until: Date().addingTimeInterval(2.5)) + XCTAssertEqual(buzzes, []) + XCTAssertEqual(controller.engine?.restRemaining(now: LiftSessionController.unixNow), remaining) + XCTAssertEqual(LiftSessionPersistence.load()?.pausedAt, controller.engine?.pausedAt) } /// When the warning and the end fire. A rest inside the warning window warns a second from now, clear of diff --git a/StrandTests/TestBundleRobustnessTests.swift b/StrandTests/TestBundleRobustnessTests.swift index 231ccaa8e3..5f8a4586f4 100644 --- a/StrandTests/TestBundleRobustnessTests.swift +++ b/StrandTests/TestBundleRobustnessTests.swift @@ -76,9 +76,16 @@ final class TestBundleRobustnessTests: XCTestCase { func testActiveDomainsIncludesUniversalOnlyWhenAModeIsOn() { // Isolate UserDefaults so the suite doesn't depend on ambient Test Centre state. let defaults = UserDefaults.standard + let keys = TestDomain.allCases.map { "testcentre.active.\($0.id)" } + let prior = keys.map { ($0, defaults.object(forKey: $0)) } + defer { + for (key, value) in prior { + if let value { defaults.set(value, forKey: key) } + else { defaults.removeObject(forKey: key) } + } + } + keys.forEach { defaults.removeObject(forKey: $0) } let key = "testcentre.active.sleep" - let prior = defaults.object(forKey: key) - defer { if let prior { defaults.set(prior, forKey: key) } else { defaults.removeObject(forKey: key) } } defaults.set(false, forKey: key) XCTAssertFalse(TestBundleAssembler.activeDomains().contains(.universal), diff --git a/StrandTests/WorkoutSaveFailureTests.swift b/StrandTests/WorkoutSaveFailureTests.swift new file mode 100644 index 0000000000..e824674b14 --- /dev/null +++ b/StrandTests/WorkoutSaveFailureTests.swift @@ -0,0 +1,59 @@ +import XCTest +import WhoopStore +import WhoopProtocol +@testable import Strand + +@MainActor +final class WorkoutSaveFailureTests: XCTestCase { + func testFailedWriteKeepsRecordedWorkoutRetryable() async throws { + let defaults = UserDefaults.standard + let keys = [ActiveWorkoutPersistence.defaultsKey] + let originals = keys.map { ($0, defaults.object(forKey: $0)) } + let previousModel = AppModel.shared + defer { + AppModel.shared = previousModel + for (key, original) in originals { + if let original { defaults.set(original, forKey: key) } + else { defaults.removeObject(forKey: key) } + } + } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let path = directory.appendingPathComponent("workout.sqlite").path + let store = try await WhoopStore(path: path) + let model = AppModel() + model.repo.setStoreForTesting(store) + let started = Date().addingTimeInterval(-600) + model.activeWorkout = AppModel.ActiveWorkout(start: started, sport: "Strength") + model.activeWorkout?.samples = [HRSample(ts: Int(started.timeIntervalSince1970), bpm: 140), + HRSample(ts: Int(started.timeIntervalSince1970) + 120, bpm: 140)] + let id = try XCTUnwrap(model.activeWorkout?.sessionID) + try execute("CREATE TRIGGER fail_workout_save BEFORE INSERT ON workout BEGIN SELECT RAISE(FAIL, 'test disk error'); END", path: path) + do { try await model.finishWorkout(); XCTFail("the write should fail") } catch {} + XCTAssertEqual(model.activeWorkout?.sessionID, id) + XCTAssertTrue(model.activeWorkout?.isPaused == true) + XCTAssertEqual(ActiveWorkoutPersistence.load()?.sessionID, id) + try execute("DROP TRIGGER fail_workout_save", path: path) + try await model.finishWorkout() + try await model.finishWorkout() + XCTAssertNil(model.activeWorkout) + XCTAssertNil(ActiveWorkoutPersistence.load()) + let rows = try await store.workouts(deviceId: model.deviceId, from: Int(started.timeIntervalSince1970), + to: Int(Date().timeIntervalSince1970), limit: 10) + XCTAssertEqual(rows.count, 1) + let row = try XCTUnwrap(rows.first) + XCTAssertEqual(row.sport, "Strength") + XCTAssertEqual(row.durationS ?? 0, 600, accuracy: 2) + XCTAssertEqual(row.avgHr, 140) + } + + private func execute(_ sql: String, path: String) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3") + process.arguments = [path, sql] + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { throw CocoaError(.fileWriteUnknown) } + } +} diff --git a/StrandiOS/App/RootTabView.swift b/StrandiOS/App/RootTabView.swift index 41c6829964..ee8dab7db2 100644 --- a/StrandiOS/App/RootTabView.swift +++ b/StrandiOS/App/RootTabView.swift @@ -141,6 +141,8 @@ struct RootTabView: View { moreTab(path: $tabPaths[4], scrollSignal: scrollTop[4]).tag(4) } .tint(StrandPalette.accent) + .onAppear { updatePerformanceTab() } + .onChangeCompat(of: selectedTab) { _ in updatePerformanceTab() } // Switching Coach off while STANDING on it leaves `selectedTab` pointing at a tag no tab claims // any more, which renders as an empty tab rather than as an error. Send that wearer to Today, and // only in that case, so a flip made from anywhere else does not move them. @@ -310,6 +312,10 @@ struct RootTabView: View { } } + private func updatePerformanceTab() { + DisplayPerformanceMonitor.shared.setTab([.today, .trends, .sleep, .coach, .more][selectedTab]) + } + /// A routed v5 pillar screen wrapped in its own nav stack + Done button (mirrors `quickScreen`). @ViewBuilder private func pillarScreen(_ dest: NavRouter.Destination) -> some View { diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index d505bc60d3..450c357878 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -155,6 +155,7 @@ struct StrandiOSApp: App { noopDeviceId: model.deviceId ) _health = StateObject(wrappedValue: bridge) + DisplayPerformanceMonitor.shared.configure(model: model, lift: liftSession) // Register a separate, always-on-while-authorized refresh task for Apple Health write-back. // The operation is write-only and bounded to the bridge's recent window; fresh BLE offloads still // use the immediate hook below. BGTaskScheduler chooses the actual wake time. @@ -346,6 +347,7 @@ struct StrandiOSApp: App { // HealthKitBridge.sync guards on `auth == .authorized`, so the scenePhase trigger stays a // safe no-op until the user opts in. .onChange(of: scenePhase, initial: true) { _, phase in + DisplayPerformanceMonitor.shared.setForegroundActive(phase == .active) if phase == .active { CoachBriefScheduler.activateIfEnabled { await model.coach.generateBrief() } model.drainPendingIntents(router: router)