diff --git a/Strand/App/ActiveWorkoutPersistence.swift b/Strand/App/ActiveWorkoutPersistence.swift index 61eefa90fa..586a3ac787 100644 --- a/Strand/App/ActiveWorkoutPersistence.swift +++ b/Strand/App/ActiveWorkoutPersistence.swift @@ -10,8 +10,9 @@ import WhoopProtocol /// records a route too, and its fixes are banked by `ActiveRouteStore` rather than by this snapshot, /// which stays the cheap per-sample write it was built as.) This is the Apple analogue of Android's /// `ActiveWorkoutStore`/`ActiveWorkoutPersistence`: a tiny `Codable` snapshot (start time, sport, the -/// accumulated HR samples + running stats) is written to `UserDefaults` on start and on every captured -/// sample, and read back on launch so an interrupted session can still be ended and saved. +/// accumulated HR samples + running stats) is checkpointed on start and lifecycle transitions. Captured +/// samples queue scoring and encoding off the main actor before the snapshot is written to `UserDefaults`. +/// It is read back on launch so an interrupted session can still be ended and saved. /// /// On-device only; mirrors the existing `moments` / `sleepMarks` `UserDefaults` persistence in `AppModel`. /// The encode/decode is pure (no `UserDefaults` dependency on the codec itself) so the persist/rehydrate @@ -69,7 +70,7 @@ enum ActiveWorkoutPersistence { ) } - /// Persist (overwrite) the snapshot. Cheap; called on start + each captured sample. + /// Persist (overwrite) a lifecycle checkpoint synchronously. static func store(_ snapshot: Snapshot, into defaults: UserDefaults = .standard) { guard let data = encode(snapshot) else { return } defaults.set(data, forKey: defaultsKey) diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 71b62e6f26..ee653a98bb 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -110,6 +110,25 @@ final class AppModel: ObservableObject { /// Android's `ActiveWorkout.gpsEnabled`. private var activeWorkoutIsGps = false + private(set) var workoutSampleRevision: UInt64 = 0 + private(set) var workoutSampleTask: Task? + private var pendingWorkoutSample: WorkoutSampleWork? + + struct WorkoutSampleWork { + let revision: UInt64 + let snapshot: ActiveWorkoutPersistence.Snapshot + let hrMax: Double + let method: StrainScorer.Method + let sex: String + + func process() -> (strain: Double, data: Data?) { + var scored = snapshot + scored.liveStrain = StrainScorer.strain(scored.samples, maxHR: hrMax, + method: method, sex: sex) ?? 0 + return (scored.liveStrain, ActiveWorkoutPersistence.encode(scored)) + } + } + /// A manual workout in progress. `samples` accumulate from the smoothed live `bpm`; `liveStrain` /// is recomputed as the window grows so the active card can show strain building in real time. struct ActiveWorkout: Equatable { @@ -155,6 +174,15 @@ final class AppModel: ObservableObject { /// second folded into `peakHr` (`recordSample`). Nil with no samples. var savedPeak: Int? { samples.map(\.bpm).max().map { max($0, peakHr) } } + var snapshot: ActiveWorkoutPersistence.Snapshot { + ActiveWorkoutPersistence.Snapshot( + startSec: Int(start.timeIntervalSince1970), sport: sport, samples: samples, + avgHr: avgHr, peakHr: peakHr, liveStrain: liveStrain, + pausedAtSec: pausedAt.map { Int($0.timeIntervalSince1970) }, + pausedDurationSec: Int(pausedDuration), sessionID: sessionID, + pausedForPulseLoss: pausedForPulseLoss, completedPauses: completedPauses) + } + /// Delegates to `ActiveWorkoutClock` so this and the two card surfaces cannot drift apart again. func elapsed(at now: Date = Date()) -> TimeInterval { ActiveWorkoutClock.activeElapsed(start: start, pausedAt: pausedAt, @@ -887,25 +915,12 @@ final class AppModel: ObservableObject { domain: .dataImport) } - /// Persist the in-flight manual workout to `UserDefaults` so it survives the app being killed mid- - /// session (#529). Called on start + each captured sample. A no-op when nothing is running. The Apple - /// analogue of Android's `persistNonGpsWorkout`. A distance workout records a route as well, and its - /// fixes are banked separately by `ActiveRouteStore`: keeping them out of here is what lets this stay - /// a small per-sample write instead of rewriting a growing route on every beat. - private func persistActiveWorkout() { + /// Bank the latest raw window at a lifecycle boundary, before the app can be suspended. + func persistActiveWorkout() { + workoutSampleRevision &+= 1 + pendingWorkoutSample = nil guard let w = activeWorkout else { return } - ActiveWorkoutPersistence.store( - ActiveWorkoutPersistence.Snapshot( - startSec: Int(w.start.timeIntervalSince1970), - sport: w.sport, - samples: w.samples, - avgHr: w.avgHr, - peakHr: w.peakHr, - liveStrain: w.liveStrain, - pausedAtSec: w.pausedAt.map { Int($0.timeIntervalSince1970) }, - pausedDurationSec: Int(w.pausedDuration), - sessionID: w.sessionID, pausedForPulseLoss: w.pausedForPulseLoss, - completedPauses: w.completedPauses)) + ActiveWorkoutPersistence.store(w.snapshot) } /// If a manual workout was in flight when iOS killed the app, rebuild `activeWorkout` from the durable @@ -974,6 +989,8 @@ final class AppModel: ObservableObject { private func clearActiveWorkout() { guard activeWorkout != nil else { return } + workoutSampleRevision &+= 1 + pendingWorkoutSample = nil activeWorkout = nil if activeWorkoutIsGps { gpsRecorder.stop() } activeWorkoutIsGps = false @@ -1091,25 +1108,49 @@ final class AppModel: ObservableObject { await repo.refresh() } - /// Append the current smoothed `bpm` to the active workout and recompute its running strain. Called - /// from `ingestHR` on every fresh sample; a no-op when no workout is running. Recomputing strain - /// over the growing window each sample is cheap at the ~1 Hz live-HR cadence. - private func captureWorkoutSample() { + /// Keep every accepted sample; score and encode immutable snapshots off the main actor. + func captureWorkoutSample(at date: Date? = nil) { guard var w = activeWorkout, !w.isPaused, let hr = bpm else { return } - // A second that already has its sample moves only the peak: publish that, and skip the rescore and the - // snapshot (the next second's sample carries the peak into the snapshot). let peakBefore = w.peakHr - guard w.recordSample(HRSample(ts: Int(Date().timeIntervalSince1970), bpm: hr)) else { - if w.peakHr != peakBefore { activeWorkout = w } + guard w.recordSample(HRSample(ts: Int((date ?? Date()).timeIntervalSince1970), bpm: hr)) else { + if w.peakHr != peakBefore { + activeWorkout = w + queueWorkoutSample(w) + } return } w.peakHr = max(w.peakHr, hr) - w.avgHr = Int((Double(w.samples.map(\.bpm).reduce(0, +)) / Double(w.samples.count)).rounded()) - w.liveStrain = StrainScorer.strain(w.samples, maxHR: Double(profile.hrMax), - method: PuffinExperiment.effortMethod, sex: profile.sex) ?? 0 + w.avgHr = Int((Double(w.samples.reduce(0) { $0 + $1.bpm }) / Double(w.samples.count)).rounded()) activeWorkout = w - // Re-snapshot the durable session so a kill keeps the latest accumulated HR window (#529). - persistActiveWorkout() + queueWorkoutSample(w) + } + + private func queueWorkoutSample(_ workout: ActiveWorkout) { + workoutSampleRevision &+= 1 + pendingWorkoutSample = WorkoutSampleWork(revision: workoutSampleRevision, + snapshot: workout.snapshot, hrMax: Double(profile.hrMax), + method: PuffinExperiment.effortMethod, sex: profile.sex) + guard workoutSampleTask == nil else { return } + workoutSampleTask = Task { [weak self] in + while let work = self?.pendingWorkoutSample { + self?.pendingWorkoutSample = nil + let result = await Task.detached(priority: .utility) { work.process() }.value + self?.applyWorkoutSample(work, result: result) + } + self?.workoutSampleTask = nil + } + } + + func applyWorkoutSample(_ work: WorkoutSampleWork, result: (strain: Double, data: Data?)) { + guard work.revision == workoutSampleRevision, var workout = activeWorkout, + workout.sessionID == work.snapshot.sessionID, !workout.isPaused else { return } + if workout.liveStrain != result.strain { + workout.liveStrain = result.strain + activeWorkout = workout + } + if let data = result.data { + UserDefaults.standard.set(data, forKey: ActiveWorkoutPersistence.defaultsKey) + } } /// Drop the smoothing window and blank the hero number so a resume / re-attach shows "," diff --git a/Strand/App/ShortcutHealthImportAlert.swift b/Strand/App/ShortcutHealthImportAlert.swift new file mode 100644 index 0000000000..73db337e25 --- /dev/null +++ b/Strand/App/ShortcutHealthImportAlert.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct ShortcutHealthImportAlert: ViewModifier { + let model: AppModel + @State private var pending: ShortcutHealthImport.PendingImport? + + private var isPresented: Binding { + Binding(get: { pending != nil }, set: { showing in + if !showing { model.cancelPendingHealthImport() } + }) + } + + func body(content: Content) -> some View { + content + .onReceive(model.$pendingShortcutHealthImport.removeDuplicates()) { pending = $0 } + .alert("Import Apple Health data?", isPresented: isPresented) { + Button("Import") { model.confirmPendingHealthImport() } + Button("Cancel", role: .cancel) { model.cancelPendingHealthImport() } + } message: { + if let pending { + Text("A Shortcut wants to add \(pending.daysCount) days and \(pending.workoutsCount) workouts to the Apple Health import source.") + } else { + Text("A Shortcut wants to add data to the Apple Health import source.") + } + } + } +} diff --git a/Strand/App/StrandApp.swift b/Strand/App/StrandApp.swift index 588e83a2e4..d701f24011 100644 --- a/Strand/App/StrandApp.swift +++ b/Strand/App/StrandApp.swift @@ -26,9 +26,11 @@ struct StrandApp: App { let router = NavRouter() _router = StateObject(wrappedValue: router) NotificationPresenter.shared.onCoachBriefTapped = { [weak router] in router?.openCoach() } + _model = State(initialValue: AppModel()) } - @StateObject private var model = AppModel() + // Own the model here; live-value views observe it without invalidating every scene on a pulse. + @State private var model: AppModel /// Shared cross-screen navigation hook (e.g. Live → Devices). The macOS shell (`RootView`) /// observes it and drives the sidebar selection. @StateObject private var router: NavRouter @@ -78,6 +80,7 @@ struct StrandApp: App { // this target is macOS 13. .onChange(of: scenePhase) { phase in if phase == .active { model.ble.requestSync(.foreground) } + if phase == .background { model.persistActiveWorkout() } } } .windowStyle(.hiddenTitleBar) diff --git a/Strand/Data/DayCycleIntelligenceIntegration.swift b/Strand/Data/DayCycleIntelligenceIntegration.swift index b7a922925a..3e262cbf58 100644 --- a/Strand/Data/DayCycleIntelligenceIntegration.swift +++ b/Strand/Data/DayCycleIntelligenceIntegration.swift @@ -219,21 +219,24 @@ import WhoopStore if let hit = cache.loads[window.sleepId], hit.key == loadKey, !hrWitness.contains(where: { $0.hasSuffix("=unread") }) { load = hit } else { - var hrByTimestamp: [Int: HRSample] = [:] - if hrEndInclusive >= window.onset { - for owner in owners { - let rows = (try? await store.hrSamples( - deviceId: owner, from: window.onset, to: hrEndInclusive, limit: 200_000)) ?? [] - for row in rows where hrByTimestamp[row.ts] == nil { hrByTimestamp[row.ts] = row } + // A day of HR merging and scoring must not block scrolling during a foreground rescore. + let scored = await Task.detached(priority: .utility) { + var hrByTimestamp: [Int: HRSample] = [:] + if hrEndInclusive >= window.onset { + for owner in owners { + let rows = (try? await store.hrSamples( + deviceId: owner, from: window.onset, to: hrEndInclusive, limit: 200_000)) ?? [] + for row in rows where hrByTimestamp[row.ts] == nil { hrByTimestamp[row.ts] = row } + } } - } - let cycleHR = hrByTimestamp.values.sorted { $0.ts < $1.ts } - load = CachedLoad( - key: loadKey, - strain: StrainScorer.strain(cycleHR, maxHR: effectiveMaxHR, restingHR: restingHR, - method: effortMethod, sex: profile.sex), - calories: cycleHR.isEmpty ? nil : Calories.estimateDayCalories( - cycleHR, profile: profile, hrmax: effectiveMaxHR, restingHR: restingHR)) + let cycleHR = hrByTimestamp.values.sorted { $0.ts < $1.ts } + return ( + strain: StrainScorer.strain(cycleHR, maxHR: effectiveMaxHR, restingHR: restingHR, + method: effortMethod, sex: profile.sex), + calories: cycleHR.isEmpty ? nil : Calories.estimateDayCalories( + cycleHR, profile: profile, hrmax: effectiveMaxHR, restingHR: restingHR)) + }.value + load = CachedLoad(key: loadKey, strain: scored.strain, calories: scored.calories) cache.loads[window.sleepId] = load } if let strain = load.strain { strains[day] = strain } diff --git a/Strand/Screens/TestCentreView.swift b/Strand/Screens/TestCentreView.swift index 9e5aa07765..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? { @@ -1007,7 +1010,11 @@ private struct TestModeRow: View { // 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.configure(live: live, repo: model.repo) + #if os(iOS) + DisplayPerformanceMonitor.shared.configure(model: model, lift: liftSession) + #else + DisplayPerformanceMonitor.shared.configure(model: model) + #endif DisplayPerformanceMonitor.shared.start() } } diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index 317d05ded0..cf25078f5e 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -4930,7 +4930,6 @@ struct TodayView: View { // trend didn't track the score it sat under. Plot the SAME merged `sleep_performance` 0–100 series // the score reads instead, windowed to the trailing 14 calendar days like every other spark. let restSparkLocal = trailingWindow(restSeries, days: 14).map { $0.value } - sparks["sleep_performance"] = restSparkLocal // The selected day's Rest, falling back to the series tail only when today itself is selected (a // navigated past day with no Rest row shows ", " rather than borrowing the newest value) AND that // tail night is still fresh. #977: a live 5.0 whose sleep never scores used to pin Rest to the @@ -4940,7 +4939,6 @@ struct TodayView: View { todayValue: restByDay[selectedDayKey], lastDay: restSeries.last?.day, lastValue: restSeries.last?.value, isTodaySelected: selectedDayOffset == 0, todayKey: selectedDayKey) - restScore = restScoreLocal // Resolve the displayed score row, then map computed rows through durable input provenance so the // badge names the sensor/import provider rather than the device that ran NOOP's math. @@ -4964,9 +4962,6 @@ struct TodayView: View { metricKey: "sleep_performance" ) } - provenanceByMetric = provenance - providerByMetric = providers - // HR trend for the SELECTED day, 5-minute bucket means from that logical day's local midnight. // For today the window runs to now (an in-progress curve); for a navigated past day it runs the // full 24h to the next midnight. The logical day rolls at 04:00 (Repository.logicalDayStart), so @@ -4995,10 +4990,9 @@ struct TodayView: View { TrendPoint(date: Date(timeIntervalSince1970: TimeInterval(b.ts)), value: b.bpm, segment: hrSegments[i]) } - hrPoints = hrPointsLocal // The chart keeps plotting means; only the footer reads the samples behind them (#2032). - hrDayMin = hrBucketsLocal.map(\.minBpm).min() - hrDayMax = hrBucketsLocal.map(\.maxBpm).max() + let hrDayMinLocal = hrBucketsLocal.map(\.minBpm).min() + let hrDayMaxLocal = hrBucketsLocal.map(\.maxBpm).max() // #316 / @63, the selected day's representative activity class for the Steps tile icon. Reads the // day's step samples (now carrying `activityClass` after the v19 column) and takes the LAST non-nil @@ -5007,7 +5001,6 @@ struct TodayView: View { // under its OWN fresh id, so a read pinned to the canonical "my-whoop" would drop the icon for a // re-added strap (the #904/#908 family). nil (no classed sample) hides the icon. let stepClassLocal = await repo.stepActivityClassLatest(from: windowStart, to: windowEndInclusive) - stepActivityClassToday = stepClassLocal // #860 item 1: the launch auto-land (#605/#739 "snap to the most recent data day when today is // empty") is RETIRED here. A fresh launch lands on today via `launchDayOffset` against the plain @@ -5052,7 +5045,6 @@ struct TodayView: View { } else { liveStrainLocal = nil } - liveTodayStrain = liveStrainLocal // Pin the chart axis to the loaded window, today midnight→now, a past day the full 24h, so // a gap (e.g. a morning the strap wasn't banking) shows as empty space, not a late start. let newAxis = Date(timeIntervalSince1970: TimeInterval(windowStart)) @@ -5062,9 +5054,6 @@ struct TodayView: View { // day opens at full scale; a same-day end-extension keeps the user's zoom but RE-CLAMPS it into the // grown bounds (preserving its span) so a live sync never yanks them out of their zoom yet the window // can never sit outside the day. `panned(deltaSeconds: 0)` is the pure re-clamp. - hrZoomDomain = Self.reclampHrZoom(hrZoomDomain, oldAxis: hrAxis, newAxis: newAxis) - hrAxis = newAxis - // Sleep session overlapping the window. Uses `allSleepSessions` (BOTH the imported and the // on-device COMPUTED source), a Bluetooth-only user's sleep lives under the computed source, // so the imported-only `sleepSessions` returns nothing. Keep blocks that actually overlap the @@ -5081,7 +5070,6 @@ struct TodayView: View { CachedSleepSession(startTs: span.start, endTs: span.end, efficiency: nil, restingHr: nil, avgHrv: nil, stagesJSON: nil) } - sleepToday = sleepTodayLocal // #932: snapshot everything just computed onto the long-lived `repo`, keyed by the (seq, day) this // pass loaded FOR (both captured at entry), so a later re-mount with the same (seq, day) restores it @@ -5089,10 +5077,21 @@ struct TodayView: View { // mid-await: a day swipe moves `selectedDayKey` (and `.task(id:)` cancels this pass) while the body // runs to completion, so its outputs can straddle two days; caching that mix under the ENTRY key // would serve it again later. The re-fired pass for the new key reloads + snapshots genuinely, so - // skipping here costs nothing but a cache miss. The snapshot is built from the LOCALS captured at - // each computation point, never from `@State` at tail time: a cancelled sibling pass's interleaved - // `@State` writes (its awaits still complete) can therefore never leak into this pass's bank. + // skipping here costs nothing but a cache miss. The snapshot uses the local query results. guard loadDayKey == selectedDayKey, !Task.isCancelled else { return } + // Commit together after the reads so each await does not rebuild the dashboard. + sparks["sleep_performance"] = restSparkLocal + restScore = restScoreLocal + provenanceByMetric = provenance + providerByMetric = providers + hrPoints = hrPointsLocal + hrDayMin = hrDayMinLocal + hrDayMax = hrDayMaxLocal + stepActivityClassToday = stepClassLocal + liveTodayStrain = liveStrainLocal + hrZoomDomain = Self.reclampHrZoom(hrZoomDomain, oldAxis: hrAxis, newAxis: newAxis) + hrAxis = newAxis + sleepToday = sleepTodayLocal repo.todayDayScopedCache = TodayDayScopedCache( restSpark: restSparkLocal, restScore: restScoreLocal, diff --git a/Strand/System/DisplayPerformanceMonitor.swift b/Strand/System/DisplayPerformanceMonitor.swift index e919e74160..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 @@ -53,6 +54,18 @@ final class DisplayPerformanceMonitor { 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 @@ -88,7 +101,13 @@ 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(live: LiveState, repo: Repository) { + 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. @@ -96,6 +115,7 @@ final class DisplayPerformanceMonitor { } 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() } @@ -116,6 +136,22 @@ final class DisplayPerformanceMonitor { 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 @@ -133,7 +169,27 @@ final class DisplayPerformanceMonitor { memoryPeakMB = 0 currentMemoryMB = nil sampleMemory() - emit?("captureStart version=\(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown") build=\(Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown")") + 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() @@ -168,6 +224,7 @@ final class DisplayPerformanceMonitor { func stop() { guard running else { return } running = false + trainingObservers.removeAll() dataVolumeTask?.cancel() dataVolumeTask = nil @@ -245,7 +302,8 @@ final class DisplayPerformanceMonitor { 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")) + syncActiveProvider.map { $0() ? "true" : "false" } ?? "n/a") + + " " + trainingContext) resetWindow() } diff --git a/StrandTests/DayCycleRecoveryTests.swift b/StrandTests/DayCycleRecoveryTests.swift index 47369732a5..a7cf83f93a 100644 --- a/StrandTests/DayCycleRecoveryTests.swift +++ b/StrandTests/DayCycleRecoveryTests.swift @@ -1,5 +1,6 @@ import XCTest import StrandAnalytics +import WhoopProtocol import WhoopStore @testable import Strand @@ -137,6 +138,58 @@ final class DayCycleRecoveryTests: XCTestCase { } } + func testCycleLoadPreservesOwnerPriorityBoundariesAndCachedResults() async throws { + let store = try await WhoopStore.inMemory() + let onset = 1_788_904_800 + let now = onset + 85_000 + let daily = recoveryDailyFixture() + let sleep = CachedSleepSession(startTs: onset, endTs: onset + 28_800, + efficiency: 0.9, restingHr: 58, avgHrv: 48, stagesJSON: nil) + let samples = (0..<80_000).map { HRSample(ts: onset + $0, bpm: 70 + $0 % 80) } + _ = try await store.insert(Streams(hr: samples), deviceId: "strap") + let duplicates = stride(from: 0, through: 80_000, by: 2).map { HRSample(ts: onset + $0, bpm: 190) } + _ = try await store.insert(Streams(hr: duplicates + [HRSample(ts: onset - 1, bpm: 200), + HRSample(ts: now, bpm: 200)]), deviceId: "canonical") + // Bit patterns recorded from this fixture before moving the existing work off the main actor. + let expected: [String: (strain: UInt64, calories: UInt64)] = [ + "male-edwards": (4_634_812_706_201_377_178, 4_663_630_262_385_791_330), + "male-banister": (4_635_039_997_245_071_032, 4_663_630_262_385_791_330), + "female-edwards": (4_634_812_706_201_377_178, 4_662_157_254_104_897_468), + "female-banister": (4_635_082_218_491_577_631, 4_662_157_254_104_897_468) + ] + for sex in ["male", "female"] { + for method in [StrainScorer.Method.edwards, .banister] { + let cache = DayCycleIntelligenceIntegration.Cache() + let profile = UserProfile(weightKg: 75, heightCm: 180, age: 35, sex: sex) + func compute() async -> DayCycleIntelligenceIntegration.Result { + await DayCycleIntelligenceIntegration.compute( + nights: [.init(daily: daily, sleeps: [sleep], workouts: [], owner: "strap")], + editedRows: [], store: store, candidates: [("strap", 0)], + physiologyOwners: ["canonical", "strap"], workouts: [], windowStart: onset, + now: now, offsetSec: 0, habitualMidsleepSec: nil, ticksPerStep: 1, + mode: .sleepOnset, cache: cache, profile: profile, maxHROverride: 190, + effortMethod: method) + } + let result = await compute() + let strain = try XCTUnwrap(result.strainByWakeDay[daily.day]) + let calories = try XCTUnwrap(result.caloriesByWakeDay[daily.day]) + let oracle = try XCTUnwrap(expected["\(sex)-\(method)"]) + XCTAssertEqual(strain.bitPattern, oracle.strain) + XCTAssertEqual(calories.bitPattern, oracle.calories) + XCTAssertEqual(result.onsetByWakeDay[daily.day], onset) + let cached = await compute() + XCTAssertEqual(cached.strainByWakeDay, result.strainByWakeDay) + XCTAssertEqual(cached.caloriesByWakeDay, result.caloriesByWakeDay) + if sex == "female", method == .banister { + _ = try await store.insert(Streams(hr: [HRSample(ts: onset + 80_001, bpm: 180)]), + deviceId: "canonical") + let updated = await compute() + XCTAssertGreaterThan(try XCTUnwrap(updated.caloriesByWakeDay[daily.day]), calories) + } + } + } + } + private func recoveryBaseline(_ mean: Double, spread: Double, status: BaselineStatus = .trusted) -> BaselineState { BaselineState(baseline: mean, spread: spread, diff --git a/StrandTests/DisplayPerformanceTests.swift b/StrandTests/DisplayPerformanceTests.swift index b8b8ee23b5..edc2a998b4 100644 --- a/StrandTests/DisplayPerformanceTests.swift +++ b/StrandTests/DisplayPerformanceTests.swift @@ -160,6 +160,107 @@ final class DisplayPerformanceTests: XCTestCase { 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() diff --git a/StrandTests/OfflineWorkoutSaveTests.swift b/StrandTests/OfflineWorkoutSaveTests.swift index dc2ad43c87..ff4f95c1ee 100644 --- a/StrandTests/OfflineWorkoutSaveTests.swift +++ b/StrandTests/OfflineWorkoutSaveTests.swift @@ -1,10 +1,20 @@ import XCTest import WhoopStore +import WhoopProtocol @testable import Strand @MainActor final class OfflineWorkoutSaveTests: XCTestCase { func testNoPulseOrGPSStillSavesAndFailedWriteKeepsTheSessionRetryable() async throws { + try await assertFailedWriteIsRetryable(samples: []) + } + + func testFailedWriteRetainsTheLatestSamplesWhileAWorkerIsPending() async throws { + let started = Int(Date().timeIntervalSince1970) - 600 + try await assertFailedWriteIsRetryable(samples: (0..<600).map { HRSample(ts: started + $0, bpm: 150) }) + } + + private func assertFailedWriteIsRetryable(samples: [HRSample]) async throws { let defaults = UserDefaults.standard let keys = [ActiveWorkoutPersistence.defaultsKey, RecordedWorkoutHistory.defaultsKey] let originals = keys.map { ($0, defaults.object(forKey: $0)) } @@ -25,12 +35,19 @@ final class OfflineWorkoutSaveTests: XCTestCase { model.repo.setStoreForTesting(store) let started = Date().addingTimeInterval(-600) model.activeWorkout = AppModel.ActiveWorkout(start: started, sport: "Strength") + for sample in samples { + model.bpm = sample.bpm + model.captureWorkoutSample(at: Date(timeIntervalSince1970: Double(sample.ts))) + } 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) + await model.workoutSampleTask?.value + XCTAssertEqual(model.activeWorkout?.samples, samples) + XCTAssertEqual(ActiveWorkoutPersistence.load()?.samples, samples) try execute("DROP TRIGGER fail_workout_save", path: path) try await model.finishWorkout() try await model.finishWorkout() @@ -42,9 +59,16 @@ final class OfflineWorkoutSaveTests: XCTestCase { let row = try XCTUnwrap(rows.first) XCTAssertEqual(row.sport, "Strength") XCTAssertEqual(row.durationS ?? 0, 600, accuracy: 2) - XCTAssertNil(row.avgHr) - XCTAssertNil(row.strain) - XCTAssertNil(row.energyKcal) + if samples.isEmpty { + XCTAssertNil(row.avgHr) + XCTAssertNil(row.strain) + XCTAssertNil(row.energyKcal) + } else { + XCTAssertEqual(row.avgHr, 150) + XCTAssertEqual(row.maxHr, 150) + XCTAssertNotNil(row.strain) + XCTAssertNotNil(row.energyKcal) + } XCTAssertTrue(RecordedWorkoutHistory.load().contains { $0.matches(row, deviceId: model.deviceId) }) } diff --git a/StrandTests/ShortcutHealthImportAlertTests.swift b/StrandTests/ShortcutHealthImportAlertTests.swift new file mode 100644 index 0000000000..66d5e07331 --- /dev/null +++ b/StrandTests/ShortcutHealthImportAlertTests.swift @@ -0,0 +1,122 @@ +import AppKit +import SwiftUI +import XCTest +import WhoopStore +@testable import Strand + +@MainActor +final class ShortcutHealthImportAlertTests: XCTestCase { + // Build each test store before AppModel's startup task can open the default database. + func testPulsesUpdateTheLeafWithoutRebuildingItsOwnerAndCancelKeepsTheStoreEmpty() async throws { + let previousModel = AppModel.shared + defer { AppModel.shared = previousModel } + let store = try await WhoopStore.inMemory() + let model = AppModel() + model.repo.setStoreForTesting(store) + let counts = Counts() + let window = host(model, counts: counts) + defer { window.orderOut(nil) } + try await wait { counts.leaf > 0 } + let ownerBefore = counts.owner + for bpm in 70...81 { + model.bpm = bpm + try await wait { counts.bpm == bpm } + } + XCTAssertEqual(counts.owner, ownerBefore) + XCTAssertGreaterThanOrEqual(counts.leaf, 12) + XCTAssertNil(window.attachedSheet) + + model.handleHealthImportURL(importURL) + try await wait { window.attachedSheet != nil } + let rows = try await store.appleDaily(deviceId: "apple-health", from: "2026-06-01", to: "2026-06-01") + XCTAssertTrue(rows.isEmpty, "Staging must not import before confirmation") + try button("Cancel", in: window).performClick(nil) + try await wait { window.attachedSheet == nil && model.pendingShortcutHealthImport == nil } + let afterCancel = try await store.appleDaily(deviceId: "apple-health", from: "2026-06-01", to: "2026-06-01") + XCTAssertTrue(afterCancel.isEmpty) + } + + func testAlreadyStagedImportIsPresentedAndConfirmationWritesThePreparedPayload() async throws { + let previousModel = AppModel.shared + defer { AppModel.shared = previousModel } + let store = try await WhoopStore.inMemory() + let model = AppModel() + model.repo.setStoreForTesting(store) + model.handleHealthImportURL(importURL) + let window = host(model, counts: Counts()) + defer { window.orderOut(nil) } + try await wait { window.attachedSheet != nil } + try button("Import", in: window).performClick(nil) + try await wait { window.attachedSheet == nil && model.pendingShortcutHealthImport == nil } + for _ in 0..<60 { + let rows = try await store.appleDaily(deviceId: "apple-health", from: "2026-06-01", to: "2026-06-01") + if !rows.isEmpty { + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows.first?.steps, 1_000) + return + } + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTFail("The confirmed payload was not imported: \(model.appleHealthImportSummary ?? "no import started")") + } + + private var importURL: URL { + let payload = Data("D,2026-06-01,1000,,,,,,,".utf8).base64EncodedString() + var url = URLComponents() + url.scheme = "noop" + url.host = "import-health" + url.queryItems = [URLQueryItem(name: "v", value: "1"), URLQueryItem(name: "payload", value: payload)] + return url.url! + } + + private func host(_ model: AppModel, counts: Counts) -> NSWindow { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 200), + styleMask: [.titled], backing: .buffered, defer: false) + window.contentView = NSHostingView(rootView: Owner(model: model, counts: counts) + .environment(\.locale, Locale(identifier: "en"))) + window.makeKeyAndOrderFront(nil) + return window + } + + private func wait(_ condition: () -> Bool) async throws { + for _ in 0..<60 { + if condition() { return } + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTFail("SwiftUI did not settle") + } + + private func button(_ title: String, in window: NSWindow) throws -> NSButton { + func find(_ view: NSView) -> NSButton? { + if let button = view as? NSButton, button.title == title { return button } + return view.subviews.lazy.compactMap(find).first + } + return try XCTUnwrap(window.attachedSheet?.contentView.flatMap(find), "Missing alert button: \(title)") + } + + private final class Counts { + var owner = 0 + var leaf = 0 + var bpm: Int? + } + + private struct Owner: View { + @State var model: AppModel + let counts: Counts + var body: some View { + counts.owner += 1 + return Pulse(counts: counts).environmentObject(model) + .modifier(ShortcutHealthImportAlert(model: model)) + } + } + + private struct Pulse: View { + @EnvironmentObject var model: AppModel + let counts: Counts + var body: some View { + counts.leaf += 1 + counts.bpm = model.bpm + return Text(model.bpm.map(String.init) ?? "—") + } + } +} diff --git a/StrandTests/WorkoutSampleBenchmarkTests.swift b/StrandTests/WorkoutSampleBenchmarkTests.swift new file mode 100644 index 0000000000..d5c70d0f6a --- /dev/null +++ b/StrandTests/WorkoutSampleBenchmarkTests.swift @@ -0,0 +1,80 @@ +import XCTest +import StrandAnalytics +import WhoopProtocol +@testable import Strand + +@MainActor +final class WorkoutSampleBenchmarkTests: XCTestCase { + func testReleaseBenchmark() async throws { + guard !_isDebugAssertConfiguration() else { throw XCTSkip("Run this benchmark in Release.") } + let defaults = UserDefaults.standard + let original = defaults.object(forKey: ActiveWorkoutPersistence.defaultsKey) + let previousModel = AppModel.shared + defer { + AppModel.shared = previousModel + if let original { defaults.set(original, forKey: ActiveWorkoutPersistence.defaultsKey) } + else { defaults.removeObject(forKey: ActiveWorkoutPersistence.defaultsKey) } + } + let model = AppModel() + model.bpm = 150 + for count in [0, 600, 3_600, 7_200] { + let start = 1_700_000_000 + let seed = (0.. 0 { samples[count - 1] = HRSample(ts: start + count + trial, bpm: 150) } + var workout = AppModel.ActiveWorkout(start: Date(timeIntervalSince1970: Double(start))) + workout.samples = samples + workout.sessionID = "synthetic-benchmark" + let (score, scoringMs) = timed { + StrainScorer.strain(samples, maxHR: Double(model.profile.hrMax), + method: PuffinExperiment.effortMethod, sex: model.profile.sex) ?? 0 + } + workout.liveStrain = score + let snapshot = ActiveWorkoutPersistence.Snapshot( + startSec: start, sport: workout.sport, samples: samples, + avgHr: workout.avgHr, peakHr: workout.peakHr, liveStrain: score, + pausedDurationSec: 0, sessionID: workout.sessionID, + pausedForPulseLoss: false, completedPauses: []) + let (data, encodingMs) = timed { ActiveWorkoutPersistence.encode(snapshot) } + XCTAssertNotNil(data) + model.activeWorkout = workout + let date = Date(timeIntervalSince1970: Double(start + count + trial + 1)) + let (_, captureMs) = timed { model.captureWorkoutSample(at: date) } + let work = AppModel.WorkoutSampleWork(revision: model.workoutSampleRevision, + snapshot: model.activeWorkout!.snapshot, hrMax: Double(model.profile.hrMax), + method: PuffinExperiment.effortMethod, sex: model.profile.sex) + let result = work.process() + let (_, mergeMs) = timed { model.applyWorkoutSample(work, result: result) } + await model.workoutSampleTask?.value + XCTAssertEqual(model.activeWorkout?.samples.count, count + 1) + if trial >= 5 { + scoreTimes.append(scoringMs) + encodeTimes.append(encodingMs) + captureTimes.append(captureMs) + mergeTimes.append(mergeMs) + mainTimes.append(captureMs + mergeMs) + } + } + print("WORKOUT_BENCHMARK samples=\(count) score=\(summary(scoreTimes)) encode=\(summary(encodeTimes)) capture=\(summary(captureTimes)) merge=\(summary(mergeTimes)) main=\(summary(mainTimes))") + if count == 7_200 { + XCTAssertLessThan(mainTimes.sorted()[Int(ceil(Double(mainTimes.count) * 0.95)) - 1], 2, + "Remaining capture + result merge must stay under 2 ms p95.") + } + } + } + + private func timed(_ body: () -> T) -> (T, Double) { + let start = DispatchTime.now().uptimeNanoseconds + let value = ObjectiveC.autoreleasepool(invoking: body) + return (value, Double(DispatchTime.now().uptimeNanoseconds - start) / 1_000_000) + } + + private func summary(_ times: [Double]) -> String { + let sorted = times.sorted() + return String(format: "%.4f/%.4f", sorted[sorted.count / 2], sorted[Int(ceil(Double(sorted.count) * 0.95)) - 1]) + } +} diff --git a/StrandTests/WorkoutSampleProcessingTests.swift b/StrandTests/WorkoutSampleProcessingTests.swift new file mode 100644 index 0000000000..64f1176bc4 --- /dev/null +++ b/StrandTests/WorkoutSampleProcessingTests.swift @@ -0,0 +1,176 @@ +import XCTest +import StrandAnalytics +import WhoopProtocol +@testable import Strand + +@MainActor +final class WorkoutSampleProcessingTests: XCTestCase { + func testCoalescingRetainsEverySampleAndMatchesTheOriginalStats() async { + await withModel { model in + var expected: [HRSample] = [] + for second in 0..<650 { + let sample = HRSample(ts: 1_700_000_000 + second + (second / 120) * 45, + bpm: second < 200 ? 150 : 110 + second % 65) + expected.append(sample) + model.bpm = sample.bpm + let date = Date(timeIntervalSince1970: Double(sample.ts)) + model.captureWorkoutSample(at: date) + model.bpm = sample.bpm + 5 + model.captureWorkoutSample(at: date) + } + await model.workoutSampleTask?.value + let workout = model.activeWorkout + XCTAssertEqual(workout?.samples, expected) + XCTAssertEqual(workout?.avgHr, Int((Double(expected.map(\.bpm).reduce(0, +)) / Double(expected.count)).rounded())) + XCTAssertEqual(workout?.peakHr, expected.map(\.bpm).max()! + 5) + XCTAssertEqual(workout?.liveStrain, StrainScorer.strain(expected, + maxHR: Double(model.profile.hrMax), method: PuffinExperiment.effortMethod, sex: model.profile.sex) ?? 0) + XCTAssertEqual(ActiveWorkoutPersistence.load(), workout?.snapshot) + XCTAssertNil(model.workoutSampleTask) + } + } + + func testWorkerPreservesBothEffortMethodsAndTheSnapshot() async { + await withModel { model in + for method in [StrainScorer.Method.edwards, .banister] { + for sex in ["male", "female"] { + let samples = (0..<650).map { HRSample(ts: 1_700_000_000 + $0 * 2, bpm: 110 + $0 % 65) } + var workout = model.activeWorkout! + workout.samples = samples + workout.avgHr = Int((Double(samples.map(\.bpm).reduce(0, +)) / Double(samples.count)).rounded()) + workout.peakHr = 185 + workout.completedPauses = [DateInterval(start: workout.start, duration: 20)] + let work = AppModel.WorkoutSampleWork(revision: 1, snapshot: workout.snapshot, + hrMax: 190, method: method, sex: sex) + let result = work.process() + let expected = StrainScorer.strain(samples, maxHR: 190, method: method, sex: sex) ?? 0 + XCTAssertEqual(result.strain, expected) + var snapshot = workout.snapshot + snapshot.liveStrain = expected + XCTAssertEqual(ActiveWorkoutPersistence.decode(result.data), snapshot) + } + } + } + } + + func testLateResultCannotUndoPause() async { + await withModel { model in + let work = captureWork(model) + let result = work.process() + model.pauseWorkout(at: Date(timeIntervalSince1970: 1_700_000_601)) + model.applyWorkoutSample(work, result: result) + await model.workoutSampleTask?.value + XCTAssertTrue(model.activeWorkout?.isPaused == true) + XCTAssertEqual(model.activeWorkout?.liveStrain, 12) + XCTAssertEqual(ActiveWorkoutPersistence.load(), model.activeWorkout?.snapshot) + } + } + + func testLateSampleResultCannotOverwriteANewerWindow() async { + await withModel { model in + model.persistActiveWorkout() + let checkpoint = ActiveWorkoutPersistence.load() + let work = captureWork(model) + let result = work.process() + model.bpm = 160 + model.captureWorkoutSample(at: Date(timeIntervalSince1970: 1_700_000_601)) + model.applyWorkoutSample(work, result: result) + XCTAssertEqual(model.activeWorkout?.samples.count, 602) + XCTAssertEqual(model.activeWorkout?.liveStrain, 12) + XCTAssertEqual(ActiveWorkoutPersistence.load(), checkpoint) + await model.workoutSampleTask?.value + XCTAssertEqual(ActiveWorkoutPersistence.load(), model.activeWorkout?.snapshot) + } + } + + func testLateResultCannotCrossResumeAndTheNextSampleStillIncludesTheFullWindow() async { + await withModel { model in + let work = captureWork(model) + let result = work.process() + model.pauseWorkout(at: Date(timeIntervalSince1970: 1_700_000_601)) + model.resumeWorkout(at: Date(timeIntervalSince1970: 1_700_000_631)) + model.applyWorkoutSample(work, result: result) + XCTAssertEqual(model.activeWorkout?.liveStrain, 12) + XCTAssertFalse(model.activeWorkout?.isPaused == true) + XCTAssertEqual(ActiveWorkoutPersistence.load(), model.activeWorkout?.snapshot) + model.captureWorkoutSample(at: Date(timeIntervalSince1970: 1_700_000_632)) + await model.workoutSampleTask?.value + XCTAssertEqual(model.activeWorkout?.samples.count, 602) + XCTAssertEqual(model.activeWorkout?.pausedDuration, 30) + XCTAssertEqual(ActiveWorkoutPersistence.load(), model.activeWorkout?.snapshot) + } + } + + func testLateResultCannotReplaceANewSession() async { + await withModel { model in + let work = captureWork(model) + let result = work.process() + model.discardWorkout() + model.startWorkout(sport: "Strength") + let id = model.activeWorkout?.sessionID + model.applyWorkoutSample(work, result: result) + await model.workoutSampleTask?.value + XCTAssertNotEqual(id, work.snapshot.sessionID) + XCTAssertEqual(model.activeWorkout?.sessionID, id) + XCTAssertEqual(model.activeWorkout?.samples.count, 0) + XCTAssertEqual(ActiveWorkoutPersistence.load(), model.activeWorkout?.snapshot) + } + } + + func testLateResultCannotResurrectAFinishedSession() async { + await withModel { model in + model.activeWorkout = AppModel.ActiveWorkout(start: Date()) + let work = captureWork(model) + let result = work.process() + do { try await model.finishWorkout() } catch { XCTFail("\(error)") } + model.applyWorkoutSample(work, result: result) + await model.workoutSampleTask?.value + XCTAssertNil(model.activeWorkout) + XCTAssertNil(ActiveWorkoutPersistence.load()) + } + } + + func testCheckpointBanksTheLatestWindowAndRejectsAnOlderResult() async { + await withModel { model in + let work = captureWork(model) + let result = work.process() + model.persistActiveWorkout() + XCTAssertEqual(ActiveWorkoutPersistence.load()?.samples.count, 601) + model.applyWorkoutSample(work, result: result) + await model.workoutSampleTask?.value + XCTAssertEqual(model.activeWorkout?.liveStrain, 12) + XCTAssertEqual(ActiveWorkoutPersistence.load(), model.activeWorkout?.snapshot) + let restored = AppModel() + XCTAssertEqual(restored.activeWorkout?.snapshot, model.activeWorkout?.snapshot) + } + } + + private func captureWork(_ model: AppModel) -> AppModel.WorkoutSampleWork { + var workout = model.activeWorkout! + workout.samples = (0..<600).map { HRSample(ts: 1_700_000_000 + $0, bpm: 150) } + workout.avgHr = 150 + workout.peakHr = 150 + workout.liveStrain = 12 + model.activeWorkout = workout + model.bpm = 150 + model.captureWorkoutSample(at: Date(timeIntervalSince1970: 1_700_000_600)) + return AppModel.WorkoutSampleWork(revision: model.workoutSampleRevision, + snapshot: model.activeWorkout!.snapshot, hrMax: Double(model.profile.hrMax), + method: PuffinExperiment.effortMethod, sex: model.profile.sex) + } + + private func withModel(_ check: (AppModel) async -> Void) async { + let defaults = UserDefaults.standard + let original = defaults.object(forKey: ActiveWorkoutPersistence.defaultsKey) + let previousModel = AppModel.shared + defer { + AppModel.shared = previousModel + if let original { defaults.set(original, forKey: ActiveWorkoutPersistence.defaultsKey) } + else { defaults.removeObject(forKey: ActiveWorkoutPersistence.defaultsKey) } + } + let model = AppModel() + model.activeWorkout = AppModel.ActiveWorkout(start: Date(timeIntervalSince1970: 1_700_000_000)) + await check(model) + await model.workoutSampleTask?.value + } +} diff --git a/StrandiOS/App/RootTabView.swift b/StrandiOS/App/RootTabView.swift index 978607e08f..f666eaa58e 100644 --- a/StrandiOS/App/RootTabView.swift +++ b/StrandiOS/App/RootTabView.swift @@ -25,11 +25,12 @@ struct RootTabView: View { /// The live gym session, owned at the app root — see `LiftSessionController`. @EnvironmentObject private var liftSession: LiftSessionController + // The shell uses repository actions only; its screens observe the data themselves. + let repo: Repository /// External entry points must wait until the mandatory first-run gates have completed. The root owns /// that state; keeping it explicit here prevents this shell's window-level sheet from covering a gate. let homeScreenQuickActionsEnabled: Bool - @EnvironmentObject private var repo: Repository /// Cross-screen navigation requests (e.g. Live → "Manage devices"). Devices isn't a tab — it lives /// behind the More list — so a request presents it as a sheet, matching the quick-action screens. @EnvironmentObject private var router: NavRouter diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index e06a838a02..addcb92bdf 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -16,7 +16,8 @@ import UserNotifications struct StrandiOSApp: App { /// UIKit bridge for Home Screen quick actions. SwiftUI keeps ownership of the scene and window. @UIApplicationDelegateAdaptor(HomeScreenQuickActionAppDelegate.self) private var appDelegate - @StateObject private var model: AppModel + // Own the model here; live-value views observe it without invalidating every scene on a pulse. + @State private var model: AppModel @StateObject private var health: HealthKitBridge /// The phone→watch link. Built + activated here so the watch app actually receives snapshots on a /// real device; without an owner that pushes it, the watch only ever shows placeholder data. @@ -81,7 +82,7 @@ struct StrandiOSApp: App { _router = StateObject(wrappedValue: router) NotificationPresenter.shared.onCoachBriefTapped = { [weak router] in router?.openCoach() } let model = AppModel() - _model = StateObject(wrappedValue: model) + _model = State(initialValue: model) CoachBriefScheduler.register(generateBrief: { [weak coach = model.coach] in await coach?.generateBrief() }, log: { [weak model] line in @@ -151,7 +152,7 @@ struct StrandiOSApp: App { noopDeviceId: model.deviceId ) _health = StateObject(wrappedValue: bridge) - DisplayPerformanceMonitor.shared.configure(live: model.live, repo: model.repo) + 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. @@ -174,48 +175,9 @@ struct StrandiOSApp: App { } } - /// The Shortcut-import alert's presentation binding, hoisted OUT of the `.alert` chain. - /// - /// An inline `Binding(get:set:)` is two untyped closures the solver must infer in place, on a - /// modifier chain that had already blown the type-check budget. Declaring it as a `Binding` - /// property replaces all of that with one known type. Hoisting the message alone was not enough — - /// the build failed again at the same modifier, which is why this one is here too. - private var healthImportAlertPresented: Binding { - Binding( - get: { model.pendingShortcutHealthImport != nil }, - set: { showing in - if !showing { model.cancelPendingHealthImport() } - } - ) - } - - /// The Shortcut-import alert's buttons, hoisted for the same reason as the binding above. - @ViewBuilder - private var healthImportAlertButtons: some View { - Button("Import") { model.confirmPendingHealthImport() } - Button("Cancel", role: .cancel) { model.cancelPendingHealthImport() } - } - - /// The Shortcut-import alert's message, hoisted OUT of the `.alert` chain. - /// - /// Not a style preference. This closure — an `if let` around two interpolated `Text`s — sits on a - /// modifier chain that grew past the Swift type-checker's budget, and the build failed with - /// "unable to type-check this expression in reasonable time" pointing at `} message: {`. The - /// expression did not change; the chain around it did. Hoisting a sub-expression into its own - /// declaration gives the solver a fixed type to work from instead of one more unknown in a chain - /// it is already struggling with. - @ViewBuilder - private var healthImportAlertMessage: some View { - if let pending = model.pendingShortcutHealthImport { - Text("A Shortcut wants to add \(pending.daysCount) days and \(pending.workoutsCount) workouts to the Apple Health import source.") - } else { - Text("A Shortcut wants to add data to the Apple Health import source.") - } - } - var body: some Scene { WindowGroup { - iOSRootView() + iOSRootView(repo: model.repo) .modifier(TrainingNotificationConfirmation(training: training)) .environmentObject(model) .environmentObject(model.ble) // #334: Today pull-to-sync reads BLEManager (no HR churn) @@ -310,11 +272,7 @@ struct StrandiOSApp: App { router.openTrainingFavorites() } } - .alert("Import Apple Health data?", isPresented: healthImportAlertPresented) { - healthImportAlertButtons - } message: { - healthImportAlertMessage - } + .modifier(ShortcutHealthImportAlert(model: model)) // Bring the watch link up once at launch (WCSession ignores a redundant activate), then // push the first snapshot so a watch that's already on-wrist gets current scores without // waiting for the next foreground. activate() is idempotent + a no-op where WC isn't @@ -380,6 +338,7 @@ struct StrandiOSApp: App { await watch.pushLatest(from: model) } } else if phase == .background { + model.persistActiveWorkout() training.reconcile() // Re-submit on every transition because iOS may discard an old best-effort request. HealthWritebackBackgroundScheduler.updateSchedule( @@ -413,6 +372,7 @@ struct StrandiOSApp: App { /// excluded `RootView()` sidebar for `RootTabView()`. The shared `OnboardingWizard`, `TermsGateView`, /// `WhatsNewView`, `AppChangelog`, and `Terms` symbols all compile into the iOS target unchanged. private struct iOSRootView: View { + let repo: Repository @AppStorage("noop.onboarded") private var onboarded = false @AppStorage("noop.lastSeenChangelogVersion") private var lastSeenChangelog = "" @AppStorage("noop.acceptedTermsVersion") private var acceptedTerms = "" @@ -443,7 +403,7 @@ private struct iOSRootView: View { private var shell: some View { ZStack { - RootTabView(homeScreenQuickActionsEnabled: + RootTabView(repo: repo, homeScreenQuickActionsEnabled: demoBypass || (onboarded && acceptedTerms == Terms.currentVersion && automaticLaunchSheetResolved)) if !onboarded && !demoBypass { diff --git a/docs/FORK_PERFORMANCE_TESTING.md b/docs/FORK_PERFORMANCE_TESTING.md index a6e0b014bd..0278629cce 100644 --- a/docs/FORK_PERFORMANCE_TESTING.md +++ b/docs/FORK_PERFORMANCE_TESTING.md @@ -128,3 +128,159 @@ No additional capture, data export, dependency or background watcher is introduc An interactive device comparison is still needed to quantify this build's hitch reduction. Raw traces and preference copies remain local and must not be attached to a public PR; only the reviewed aggregate statistics above are suitable to share. + +## Build 451 usage review (October 11) + +The latest local archive contains 69 build-451 summary windows, about 298 sampled +seconds. Of those, Today accounts for 270 seconds and Trends for only four seconds. +The following comparison excludes windows overlapping the first ten seconds of +each foreground capture and includes only windows with sync inactive: + +| Today | Build 450 | Build 451 | +| --- | ---: | ---: | +| Sampled seconds | 182.02 | 185.10 | +| Late callbacks, weighted by callback count | 1.87% | 0.74% | +| Worst callback interval | 517.9 ms | 98.5 ms | +| Median window p95 | 16.7 ms | 16.7 ms | + +Both sets have nominal thermal state and Low Power Mode off. This is ordinary use, +with different interactions and foreground sessions. It suggests an improvement +but cannot establish that the training optimization caused it. The new summaries +do not identify active workouts, and the Trends sample is too short for a useful +comparison. These are callback intervals, not rendered FPS. + +The follow-up moves the selected day's existing UI assignments after its last +query and cancellation/day check. Previously, six groups of assignments were +separated by awaits. Rest, provenance, HR, steps, Effort, axis and sleep now commit +together. Queries, formulas and cache contents stay the same. An overtaken load +also leaves the displayed state intact instead of publishing partial results. + +A reduced native SwiftUI chart probe ran five times in optimized mode. Six staged +publications caused seven body evaluations in every run, including initial layout. +One final publication caused two. This verifies the coalescing behavior, not the +number of evaluations in production Today or an iPhone scrolling speedup. +Raw device archives and the synthetic probe remain in ignored local build files. + +## Training context in build 453 + +Display & Performance summaries now include `workout=none|active|paused` and +`lift=none|active|paused`. The two fields are independent because a sport recording +and Lift Log can run together. They describe the app's session state, including +automatic pauses. They do not prove that the wearer was exercising. +An unavailable session source is reported as `n/a`, including Lift Log in the +macOS Test Centre, which has no shared Lift session controller. + +Starting, pausing, resuming or ending either session flushes the existing frame +window and writes a `trainingPhase` marker with the foreground capture's elapsed +time and tab. Ordinary sample and set updates do not split windows. The callback +interval crossing a phase change belongs to the following phase, as with tab +changes, so a transition hitch is retained rather than discarded. + +Each `captureStart` reads the current sessions, including a paused session restored +at launch or changes made while the app was in the background. The observers run +only while capture is running. The existing foreground-only capture, opt-in switch, +bounded local archive and export path remain in use. These fields contain no +workout name, session identifier, pulse measurement or set details. + +Build 452 does not contain these fields. The next device comparison needs build +453 with Display & Performance enabled, covering training on Today and Trends +as well as a few pauses and periods without a training session. + +## App and tab update scope (October 11) + +A fresh read of the iPhone archive contains 126 build-453 windows. Excluding windows +that overlap the first ten seconds of a foreground capture, and selecting Today +with sync inactive and both training phases `none`, leaves 99 windows: 483.81 sampled +seconds, 28,958 callbacks, 0.418% late callbacks and a worst interval of 63.0 ms. +The median window p95 is 16.7 ms, thermal state is nominal and Low Power Mode is off. +No Trends windows satisfy that selection. These are ordinary-use callback timings +before the update-scope change, not a controlled scrolling comparison. + +Both Apple app entries now retain `AppModel` in native `State`. Screens and live +leaves still observe the same model through their environment, while a changed +smoothed pulse or workout sample no longer invalidates the app's entire scene +builder. The iOS Shortcuts import confirmation separately subscribes to the pending +import publisher; it still stages before confirmation, imports on acceptance and +discards on cancellation. Widget and lifecycle subscriptions retain their triggers. + +The iOS tab shell receives the same repository as a plain reference because it uses +only refresh and backup actions, not displayed data. Screen-level repository +observations remain responsible for new readings. Tab selection, navigation paths +and the existing refresh actions retain their state and behavior. + +A reduced native SwiftUI app probe sends twelve pulse changes through each ownership +pattern. The observed owner evaluates its scene builder 13 times, while the passive +owner evaluates it once; the pulse leaf evaluates 13 times in both cases. A separate +hosted-view probe repeats the result three times and verifies that pending-import +state still updates independently. These probes establish the removed invalidation +path, not the number of evaluations in production or an iPhone hitch reduction. +Raw archives and probes remain in ignored `build/performance/ui-update-oct11/`. + +Both Apple Release targets compile. The macOS run passes 63 focused tests, +including hosted-view checks that pulse leaves keep updating without rebuilding +their owner, cancellation leaves the store empty, and an import staged before the +alert mounts is still presented and written only after confirmation. The 338 Tools +Python tests pass with a canonical temporary path (`TMPDIR=/private/tmp`); the host's +default `/var` alias otherwise fails four existing `/private/var` path expectations. + +This change concerns Apple UI scheduling. It adds no analytics, stored values, +dependency or strap command. An installed-device comparison remains required to +quantify the remaining scrolling improvement. + +## Ordinary-use review and day-cycle workload (October 14) + +The build-454 archive contains 130 summary windows from 43 foreground captures. +Every capture starts with workout and Lift Log both `none`. Today accounts for +204.96 sampled seconds across 61 windows, with a worst callback interval of +265.7 ms. Trends has 48.20 seconds across 23 windows, with a worst of 220.6 ms. +Long intervals also occur on Sleep and Coach, so the remaining stalls reach +beyond either dashboard chart. + +Most of these windows overlap the first ten seconds after returning to the app. +Applying the previous comparison's filters leaves this Today subset: + +| Today, no training or sync, after the first ten seconds | Build 453 | Build 454 | +| --- | ---: | ---: | +| Windows | 99 | 11 | +| Sampled seconds | 483.81 | 34.93 | +| Late callbacks, weighted by callback count | 0.418% | 1.232% | +| Worst callback interval | 63.0 ms | 159.6 ms | +| Median window p95 | 16.7 ms | 16.7 ms | + +Both subsets have nominal thermal state and Low Power Mode off. The short new +subset has more late callbacks, but its different interactions and capture lengths +prevent a controlled regression estimate. These data do not establish an improvement +from the previous shell change. There is still no matching build-453 Trends subset. +The build-454 Trends selection contains seven windows and 13.13 sampled seconds. + +Retained app logs place long callback windows near foreground rescores. One completed +pass reports 4.176 seconds elapsed and 4.0 CPU seconds, with 609 ms in its `score2` +phase. These are whole-phase costs including database waits, not one continuous +UI-thread pause. Existing Time Profiler samples also contain intelligence-engine +work on the main thread alongside substantial SwiftUI graph and rendering work. + +Code review found a remaining main-thread workload inside that phase: +`DayCycleIntelligenceIntegration.compute` is main-actor isolated. On a cycle-load +cache miss, it reads up to 200,000 HR points per owner, merges their timestamps, +sorts the union and computes Effort and calories. The reads suspend, but the merge, +sort and scoring previously resumed on the main actor. This also runs without a +training session and can contend with any visible tab during a foreground rescore. + +That existing read-and-score block now runs in a utility-priority detached task. +Cache access and the result merge remain on the main actor. Owner precedence, +inclusive read limits, duplicate handling, formulas and stored values stay the same. +An in-memory integration test uses 80,000 primary samples, conflicting timestamps +from a second owner and out-of-range points. Its expected Effort and calorie bit +patterns were recorded before the change for both methods and both sex-specific +calorie paths. The test also checks cache reuse and invalidation after a new sample. + +Local heartbeat measurements varied too much to estimate a hitch reduction. +The fixed build has not been measured on the iPhone. The existing callback and +Instruments records cannot attribute every remaining stall to this calculation. +Raw archives and local checks remain in ignored `build/performance/lag-review-oct14/`. + +Both Apple Release targets compile, and 50 focused macOS tests pass. All 338 Tools +Python checks, source hygiene, translation coverage and source and binary policy +checks pass. The hosted import tests now create their private store before the app +model, avoiding a startup-open race in the test fixture. Android is unchanged because +this moves Apple work between executors without changing calculations or storage. diff --git a/docs/FORK_TRAINING_PERFORMANCE.md b/docs/FORK_TRAINING_PERFORMANCE.md new file mode 100644 index 0000000000..e8d8e6ce4d --- /dev/null +++ b/docs/FORK_TRAINING_PERFORMANCE.md @@ -0,0 +1,93 @@ +# Manual workout sample processing + +The local build-450 recording contains 105 seconds of foreground use during a normal +sport recording, including a 518 ms callback interval on Today. It measures delayed +display-link callbacks, without call stacks that would attribute those delays to a +particular function. Raw device logs remain in ignored local storage. + +The baseline sample path calculated a mean, fingerprinted and scored the growing +pulse window, then JSON-encoded and stored the entire workout snapshot on the main +actor. The benchmark calls those production functions with synthetic samples. + +## Reproducing the baseline + +```sh +xcodegen generate +xcodebuild -project Strand.xcodeproj -scheme Strand -configuration Release \ + -destination 'platform=macOS,arch=arm64' -derivedDataPath build/training-macos-release \ + ARCHS=arm64 ONLY_ACTIVE_ARCH=YES ENABLE_TESTABILITY=YES \ + 'OTHER_SWIFT_FLAGS=$(inherited) -D DEBUG' \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY= \ + -only-testing:StrandTests/WorkoutSampleBenchmarkTests test +``` + +The existing test bundle requires DEBUG-only test accessors. The command enables +those accessors while retaining Release optimization (`-O`); the measured capture, +scoring and encoding functions have no DEBUG alternative. Device builds use ordinary +Release settings. The benchmark skips unoptimized Debug builds. + +Each size gets five warmups and 101 measured iterations. Nonempty scoring windows +have a different fingerprint on each iteration, so scoring includes a cache miss. +Scoring and encoding are timed separately. Baseline capture is the complete synchronous +`AppModel.captureWorkoutSample` call, including publisher callbacks and persistence. +Its input starts with the listed number of samples and accepts one additional sample. +Initialization, fixture creation and result assertions are outside the timed calls. + +| Starting samples | Scoring median / p95 | Encoding median / p95 | Synchronous capture median / p95 | +| --- | --- | --- | --- | +| 0 | 0.0053 / 0.0065 ms | 0.0039 / 0.0065 ms | 0.1702 / 0.2253 ms | +| 600 | 0.0078 / 0.0099 ms | 0.3110 / 0.3394 ms | 0.4922 / 0.5579 ms | +| 3,600 | 0.0203 / 0.0215 ms | 1.8089 / 1.8705 ms | 2.1028 / 2.1775 ms | +| 7,200 | 0.0353 / 0.0395 ms | 3.6312 / 3.7570 ms | 4.1668 / 4.2842 ms | + +Encoding accounts for most of this measured cost. Moving it off the main actor is +worth testing, but these numbers do not explain the full 518 ms device delay. + +## After moving scoring and encoding off the main actor + +The mean uses a single reduction without an intermediate array. A main-actor task +awaits one detached scoring-and-encoding job at a time, with one latest pending +snapshot. Samples stay in `activeWorkout`; coalescing replaces derived work, without +dropping readings. Higher peaks from duplicate seconds also replace pending work. +Results must match both session ID and revision. Start, pause, resume, discard and +background checkpoints invalidate older results. Persistence retains its key and +Codable shape, with no added checkpoint timer. Lifecycle checkpoints synchronously +bank the full window and its latest completed Effort estimate; final saving still +recomputes Effort using the existing resting-HR input. + +The updated benchmark times capture and a valid result merge separately. The merge +includes publishing a changed Effort estimate and `UserDefaults.set` of encoded data. +Preparation and computation of that result are outside these main-actor timings. +The table sums capture and merge for each trial before taking its median and p95. +The production worker drains before the next trial. Actor scheduling and SwiftUI +rendering are outside these function timings. A 7,200-sample p95 above 2 ms fails the +opt-in Release benchmark. + +| Starting samples | Capture median / p95 | Result merge median / p95 | Main-actor total median / p95 | +| --- | --- | --- | --- | +| 0 | 0.0145 / 0.0170 ms | 0.3911 / 0.4361 ms | 0.4064 / 0.4535 ms | +| 600 | 0.0150 / 0.0190 ms | 0.3911 / 0.4522 ms | 0.4051 / 0.4676 ms | +| 3,600 | 0.0175 / 0.0255 ms | 0.3996 / 0.4639 ms | 0.4177 / 0.4837 ms | +| 7,200 | 0.0218 / 0.0355 ms | 0.5859 / 0.6795 ms | 0.6102 / 0.7050 ms | + +The same isolated scoring calls after the change measured median / p95 of +0.0219 / 0.0268, 0.0262 / 0.0332, 0.0393 / 0.0459 and 0.0551 / 0.0663 ms for the +four sizes. Encoding measured 0.0214 / 0.0259, 0.3445 / 0.3962, 1.8802 / 2.1310 and +3.8180 / 4.1372 ms. Those functions retain their algorithms and run off the main actor +in the sample path; this change targets main-actor occupancy rather than total CPU. + +At 7,200 starting samples, measured main-actor p95 falls from 4.2842 to 0.7050 ms, +about 84%. The 35 focused macOS tests pass, covering numeric and snapshot equivalence, +unchanged pulse values, duplicate seconds and measurement gaps; obsolete results +after newer samples, pause, resume, replacement, checkpoint and finish; and a failed +database write followed by a successful retry with every sample retained. + +No chart renderer, nightly analysis, global Today refresh, widget comparison or +ActivityKit throttle was changed. This is Apple app scheduling and encoding; the +shared numerical and storage contracts are unchanged. Android is outside this fork +change. An iPhone comparison after installation is still required before claiming +the recorded scrolling stalls are resolved. + +These are local CPU costs on an Apple M4 Max with Xcode 27.0. They do not measure +iPhone scrolling, rendered frames, GPU work or physical disk durability. UserDefaults +writes retain the platform's existing persistence semantics.