Просмотр исходного кода

Merge branch 'dev' into fix-spurious-pump-alerts

Deniz Cengiz 1 день назад
Родитель
Сommit
d456d69b5c
88 измененных файлов с 29560 добавлено и 2500 удалено
  1. 3 0
      .gitmodules
  2. 1 1
      Config.xcconfig
  3. 1 0
      EversenseKit
  4. 5 1
      LiveActivity/LiveActivity.swift
  5. 97 4
      LiveActivity/Views/LiveActivityChartView.swift
  6. 6 82
      Model/CoreDataObserver.swift
  7. 41 14
      Model/CoreDataStack.swift
  8. 6 0
      Model/Helper/NSPredicates.swift
  9. 9 0
      Model/NSFetchedResultsControllerDelegate.swift
  10. 1 1
      Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents
  11. 14 0
      Trio.xcodeproj/project.pbxproj
  12. 3 0
      Trio.xcworkspace/contents.xcworkspacedata
  13. 109 64
      Trio/Sources/APS/APSManager.swift
  14. 22 16
      Trio/Sources/APS/DeviceDataManager.swift
  15. 6 4
      Trio/Sources/APS/FetchGlucoseManager.swift
  16. 0 1
      Trio/Sources/APS/FetchTreatmentsManager.swift
  17. 58 37
      Trio/Sources/APS/OpenAPS/OpenAPS.swift
  18. 1 1
      Trio/Sources/APS/OpenAPSSwift/Profile/Carbs.swift
  19. 6 0
      Trio/Sources/APS/PluginManager.swift
  20. 29 20
      Trio/Sources/APS/Storage/CarbsStorage.swift
  21. 19 12
      Trio/Sources/APS/Storage/ContactImageStorage.swift
  22. 30 27
      Trio/Sources/APS/Storage/DeterminationStorage.swift
  23. 74 37
      Trio/Sources/APS/Storage/GlucoseStorage.swift
  24. 35 12
      Trio/Sources/APS/Storage/OverrideStorage.swift
  25. 103 131
      Trio/Sources/APS/Storage/PumpHistoryStorage.swift
  26. 54 28
      Trio/Sources/APS/Storage/TDDStorage.swift
  27. 39 16
      Trio/Sources/APS/Storage/TempTargetsStorage.swift
  28. 1 0
      Trio/Sources/Helpers/CGMOptions.swift
  29. 27011 692
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  30. 7 0
      Trio/Sources/Models/BolusStatus.swift
  31. 5 0
      Trio/Sources/Models/TrioSettings.swift
  32. 0 1
      Trio/Sources/Modules/Adjustments/AdjustmentsStateModel.swift
  33. 3 2
      Trio/Sources/Modules/DynamicSettings/DynamicSettingsStateModel.swift
  34. 0 2
      Trio/Sources/Modules/History/HistoryStateModel.swift
  35. 18 28
      Trio/Sources/Modules/Home/HomeStateModel+Setup/BatterySetup.swift
  36. 26 53
      Trio/Sources/Modules/Home/HomeStateModel+Setup/CarbSetup.swift
  37. 47 92
      Trio/Sources/Modules/Home/HomeStateModel+Setup/ChartAxisSetup.swift
  38. 13 41
      Trio/Sources/Modules/Home/HomeStateModel+Setup/CurrentTDDSetup.swift
  39. 47 45
      Trio/Sources/Modules/Home/HomeStateModel+Setup/DeterminationSetup.swift
  40. 21 0
      Trio/Sources/Modules/Home/HomeStateModel+Setup/ForecastSetup.swift
  41. 19 34
      Trio/Sources/Modules/Home/HomeStateModel+Setup/GlucoseSetup.swift
  42. 28 57
      Trio/Sources/Modules/Home/HomeStateModel+Setup/OverrideSetup.swift
  43. 34 69
      Trio/Sources/Modules/Home/HomeStateModel+Setup/PumpHistorySetup.swift
  44. 28 58
      Trio/Sources/Modules/Home/HomeStateModel+Setup/TempTargetSetup.swift
  45. 300 130
      Trio/Sources/Modules/Home/HomeStateModel.swift
  46. 28 23
      Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift
  47. 1 1
      Trio/Sources/Modules/Home/View/Chart/MainChartView.swift
  48. 13 7
      Trio/Sources/Modules/Home/View/HomeRootView.swift
  49. 0 2
      Trio/Sources/Modules/ISFEditor/ISFEditorStateModel.swift
  50. 2 0
      Trio/Sources/Modules/LiveActivitySettings/LiveActivitySettingsStateModel.swift
  51. 34 0
      Trio/Sources/Modules/LiveActivitySettings/View/LiveActivitySettingsRootView.swift
  52. 0 2
      Trio/Sources/Modules/NightscoutConfig/NightscoutConfigStateModel.swift
  53. 50 17
      Trio/Sources/Modules/Settings/View/SettingsRootView.swift
  54. 2 0
      Trio/Sources/Modules/Settings/View/Subviews/SubmodulesView.swift
  55. 6 0
      Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift
  56. 6 0
      Trio/Sources/Modules/Stat/StatStateModel+Setup/BolusStatsSetup.swift
  57. 10 1
      Trio/Sources/Modules/Stat/StatStateModel+Setup/LoopChartSetup.swift
  58. 3 0
      Trio/Sources/Modules/Stat/StatStateModel+Setup/MealStatsSetup.swift
  59. 17 4
      Trio/Sources/Modules/Stat/StatStateModel+Setup/TDDSetup.swift
  60. 3 5
      Trio/Sources/Modules/Stat/StatStateModel.swift
  61. 214 223
      Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift
  62. 14 9
      Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift
  63. 4 3
      Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift
  64. 8 5
      Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift
  65. 2 3
      Trio/Sources/Services/BolusSafety/BolusSafetyValidator.swift
  66. 9 6
      Trio/Sources/Services/Calendar/CalendarManager.swift
  67. 9 6
      Trio/Sources/Services/ContactImage/ContactImageManager.swift
  68. 101 60
      Trio/Sources/Services/HealthKit/HealthKitManager.swift
  69. 3 2
      Trio/Sources/Services/IOB/IOBService.swift
  70. 47 5
      Trio/Sources/Services/LiveActivity/Data/DataManager.swift
  71. 3 0
      Trio/Sources/Services/LiveActivity/Data/DeterminationData.swift
  72. 9 0
      Trio/Sources/Services/LiveActivity/LiveActitiyAttributes.swift
  73. 10 1
      Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift
  74. 6 5
      Trio/Sources/Services/LiveActivity/LiveActivityManager.swift
  75. 45 56
      Trio/Sources/Services/Network/Nightscout/BaseNightscoutManager+Subscribers.swift
  76. 217 52
      Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift
  77. 173 136
      Trio/Sources/Services/Network/TidepoolManager.swift
  78. 8 0
      Trio/Sources/Services/Notifications/SwiftNotificationCenter/WeakObjectSet.swift
  79. 5 4
      Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift
  80. 22 13
      Trio/Sources/Services/WatchManager/AppleWatchManager.swift
  81. 17 12
      Trio/Sources/Services/WatchManager/GarminManager.swift
  82. 0 1
      Trio/Sources/Shortcuts/BaseIntentsRequest.swift
  83. 15 6
      Trio/Sources/Shortcuts/Override/OverridePresetsIntentRequest.swift
  84. 0 2
      Trio/Sources/Shortcuts/State/StateIntentRequest.swift
  85. 18 7
      Trio/Sources/Shortcuts/TempPresets/TempPresetsIntentRequest.swift
  86. 27 1
      Trio/Sources/Views/SnoozeAlertsSheetView.swift
  87. 6 6
      TrioTests/CoreDataTests/TestAssembly.swift
  88. 13 1
      TrioTests/OpenAPSSwiftTests/ProfileCarbsTests.swift

+ 3 - 0
.gitmodules

@@ -31,3 +31,6 @@
 [submodule "OmnipodKit"]
 	path = OmnipodKit
 	url = https://github.com/loopandlearn/OmnipodKit
+[submodule "EversenseKit"]
+	path = EversenseKit
+	url = https://github.com/loopandlearn/EversenseKit

+ 1 - 1
Config.xcconfig

@@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group
 
 // The developers set the version numbers, please leave them alone
 APP_VERSION = 0.8.4
-APP_DEV_VERSION = 0.8.4.7
+APP_DEV_VERSION = 0.8.4.15
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 

+ 1 - 0
EversenseKit

@@ -0,0 +1 @@
+Subproject commit 66979665bc73137b0a70e9b4f6f88f21558fa4c9

+ 5 - 1
LiveActivity/LiveActivity.swift

@@ -126,7 +126,11 @@ private extension LiveActivityAttributes.ContentState {
             tempTargetDate: Date().addingTimeInterval(-1800),
             tempTargetDuration: 60,
             tempTargetTarget: 120,
-            widgetItems: LiveActivityAttributes.LiveActivityItem.defaultItems
+            widgetItems: LiveActivityAttributes.LiveActivityItem.defaultItems,
+            minForecast: [],
+            maxForecast: [],
+            forecastLines: [],
+            forecastDisplayType: "cone"
         )
 
     // 0 is the widest digit. Use this to get an upper bound on text width.

+ 97 - 4
LiveActivity/Views/LiveActivityChartView.swift

@@ -9,6 +9,11 @@ import Foundation
 import SwiftUI
 import WidgetKit
 
+private enum ForecastDisplayTypeKey {
+    static let lines = "lines"
+    static let cone = "cone"
+}
+
 struct LiveActivityChartView: View {
     @Environment(\.colorScheme) var colorScheme
     @Environment(\.isWatchOS) var isWatchOS
@@ -22,9 +27,13 @@ struct LiveActivityChartView: View {
 
         let maxThreshhold: Decimal = isWatchOS ? 220 : 300
 
-        // Determine scale
-        let minValue = min(additionalState.chart.min(by: { $0.value < $1.value })?.value ?? 39, 39)
-        let maxValue = max(additionalState.chart.max(by: { $0.value < $1.value })?.value ?? maxThreshhold, maxThreshhold)
+        // Determine scale, accounting for both glucose history and prediction values
+        let chartMin = additionalState.chart.min(by: { $0.value < $1.value })?.value ?? 39
+        let chartMax = additionalState.chart.max(by: { $0.value < $1.value })?.value ?? maxThreshhold
+        let forecastMin = additionalState.minForecast.min().map { Decimal($0) } ?? chartMin
+        let forecastMax = min(additionalState.maxForecast.max().map { Decimal($0) } ?? chartMax, maxThreshhold)
+        let minValue = min(min(chartMin, forecastMin), 39)
+        let maxValue = max(max(chartMax, forecastMax), maxThreshhold)
 
         let yAxisRuleMarkMin = isMgdL ? state.lowGlucose : state.lowGlucose
             .asMmolL
@@ -34,12 +43,22 @@ struct LiveActivityChartView: View {
 
         let isOverrideActive = additionalState.isOverrideActive == true
         let isTempTargetActive = additionalState.isTempTargetActive == true
+        let hasForecast = !additionalState.minForecast.isEmpty || !additionalState.forecastLines.isEmpty
 
         let calendar = Calendar.current
         let now = Date()
 
         let startDate = calendar.date(byAdding: .hour, value: isWatchOS ? -3 : -6, to: now) ?? now
-        let endDate = calendar.date(byAdding: .minute, value: isWatchOS ? 5 : 0, to: now) ?? now
+        let endDate: Date = {
+            let baseEnd = calendar.date(byAdding: .minute, value: isWatchOS ? 5 : 0, to: now) ?? now
+            guard hasForecast, let anchorDate = state.date else { return baseEnd }
+            let forecastCount = max(
+                additionalState.minForecast.count,
+                additionalState.forecastLines.max(by: { $0.values.count < $1.values.count })?.values.count ?? 0
+            )
+            let predictionEnd = anchorDate.addingTimeInterval(TimeInterval(forecastCount * 300))
+            return max(baseEnd, predictionEnd)
+        }()
 
         // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
         let hardCodedLow = isMgdL ? Decimal(55) : 55.asMmolL
@@ -83,6 +102,14 @@ struct LiveActivityChartView: View {
                 drawActiveTempTarget()
             }
 
+            if hasForecast, let anchorDate = state.date {
+                if additionalState.forecastDisplayType == ForecastDisplayTypeKey.lines {
+                    drawForecastLines(anchorDate: anchorDate, isMgdL: isMgdL)
+                } else {
+                    drawForecastCone(anchorDate: anchorDate, isMgdL: isMgdL, maxValue: maxValue)
+                }
+            }
+
             drawChart(yAxisRuleMarkMin: yAxisRuleMarkMin, yAxisRuleMarkMax: yAxisRuleMarkMax)
         }
         .chartYAxis {
@@ -149,6 +176,72 @@ struct LiveActivityChartView: View {
         .lineStyle(.init(lineWidth: 8))
     }
 
+    private func timeForIndex(_ index: Int, anchorDate: Date) -> Date {
+        anchorDate.addingTimeInterval(TimeInterval(index * 300))
+    }
+
+    private func drawForecastCone(anchorDate: Date, isMgdL: Bool, maxValue: Decimal) -> some ChartContent {
+        let minForecast = additionalState.minForecast
+        let maxForecast = additionalState.maxForecast
+        let cappedMax = isMgdL ? maxValue : maxValue.asMmolL
+        let count = min(minForecast.count, maxForecast.count)
+
+        // Pre-compute cone data to avoid conditionals inside the ForEach closure
+        let coneData: [(date: Date, yMin: Decimal, yMax: Decimal)] = (0 ..< count).map { index in
+            let xValue = timeForIndex(index, anchorDate: anchorDate)
+            let delta = minForecast[index] - maxForecast[index]
+            let yMin: Decimal
+            let yMax: Decimal
+            if delta == 0 {
+                let base = isMgdL ? Decimal(minForecast[index]) : Decimal(minForecast[index]).asMmolL
+                yMin = base - 1
+                yMax = base + 1
+            } else {
+                yMin = isMgdL ? Decimal(minForecast[index]) : Decimal(minForecast[index]).asMmolL
+                yMax = isMgdL ? Decimal(maxForecast[index]) : Decimal(maxForecast[index]).asMmolL
+            }
+            return (date: xValue, yMin: min(yMin, cappedMax), yMax: min(yMax, cappedMax))
+        }
+
+        return ForEach(coneData.indices, id: \.self) { i in
+            AreaMark(
+                x: .value("Time", coneData[i].date),
+                yStart: .value("Min", coneData[i].yMin),
+                yEnd: .value("Max", coneData[i].yMax)
+            )
+            .foregroundStyle(Color.blue.opacity(0.5))
+            .interpolationMethod(.linear)
+        }
+    }
+
+    private func drawForecastLines(anchorDate: Date, isMgdL: Bool) -> some ChartContent {
+        let colorMap: [String: Color] = [
+            "iob": Color(red: 0.118, green: 0.588, blue: 0.988),
+            "cob": Color.orange,
+            "uam": Color(red: 0.820, green: 0.169, blue: 0.969),
+            "zt": Color(red: 0.443, green: 0.380, blue: 0.937)
+        ]
+
+        let points: [(series: String, date: Date, value: Decimal)] = additionalState.forecastLines.flatMap { line in
+            line.values.enumerated().map { index, value in
+                let displayValue = isMgdL ? Decimal(value) : Decimal(value).asMmolL
+                return (series: line.type, date: timeForIndex(index, anchorDate: anchorDate), value: displayValue)
+            }
+        }
+
+        return ForEach(0 ..< points.count, id: \.self) { i in
+            let point = points[i]
+            LineMark(
+                x: .value("Time", point.date),
+                y: .value("Value", point.value),
+                series: .value("Type", point.series)
+            )
+            .foregroundStyle(colorMap[point.series] ?? Color.gray)
+            .lineStyle(.init(lineWidth: 1.5))
+            .interpolationMethod(.linear)
+        }
+    }
+
     private func drawChart(yAxisRuleMarkMin _: Decimal, yAxisRuleMarkMax _: Decimal) -> some ChartContent {
         // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
         let hardCodedLow = Decimal(55)

+ 6 - 82
Model/CoreDataObserver.swift

@@ -2,79 +2,20 @@ import Combine
 import CoreData
 import Foundation
 
-/// Represents the types of Core Data changes that can be observed
-/// Use as an option set to specify which types of changes to monitor
-public struct CoreDataChangeTypes: OptionSet {
-    /// The raw integer value used to store the option set bits
-    public let rawValue: Int
-
-    /// Required initializer for OptionSet conformance
-    public init(rawValue: Int) {
-        self.rawValue = rawValue
-    }
-
-    /// Represents newly created/inserted objects in Core Data
-    /// Binary: 001 (1 << 0)
-    public static let inserted = CoreDataChangeTypes(rawValue: 1 << 0)
-
-    /// Represents modified/updated objects in Core Data
-    /// Binary: 010 (1 << 1)
-    public static let updated = CoreDataChangeTypes(rawValue: 1 << 1)
-
-    /// Represents removed/deleted objects in Core Data
-    /// Binary: 100 (1 << 2)
-    public static let deleted = CoreDataChangeTypes(rawValue: 1 << 2)
-
-    /// Convenience option that includes all possible change types
-    /// This combines inserted, updated, and deleted into a single option
-    public static let all: CoreDataChangeTypes = [.inserted, .updated, .deleted]
-}
-
-/// Creates a publisher that emits sets of NSManagedObjectIDs when Core Data changes occur
-/// - Parameter changeTypes: The types of changes to observe (defaults to .all)
-/// - Returns: A publisher that emits Sets of NSManagedObjectIDs for the specified change types
-func changedObjectsOnManagedObjectContextDidSavePublisher(
-    observing changeTypes: CoreDataChangeTypes = .all
-) -> some Publisher<Set<NSManagedObjectID>, Never> {
-    Foundation.NotificationCenter.default
-        .publisher(for: .NSManagedObjectContextDidSave)
-        .compactMap { notification -> Set<NSManagedObjectID>? in
-
-            var objectIDs = Set<NSManagedObjectID>()
-
-            // Process inserted objects if requested
-            if changeTypes.contains(.inserted) {
-                objectIDs.formUnion(notification.insertedObjectIDs)
-            }
-
-            // Process updated objects if requested
-            if changeTypes.contains(.updated) {
-                objectIDs.formUnion(notification.updatedObjectIDs)
-            }
-
-            // Process deleted objects if requested
-            if changeTypes.contains(.deleted) {
-                objectIDs.formUnion(notification.deletedObjectIDs)
-            }
-
-            // Only emit non-empty sets
-            return objectIDs.isEmpty ? nil : objectIDs
-        }
-}
+// The app-wide Core Data change feed now lives on `CoreDataStack.entityChangePublisher`, which is
+// sourced from persistent history (and therefore also covers batch operations and cross-process
+// changes). This file only keeps the `filteredByEntityName` operator used by its subscribers.
 
 extension Publisher where Output == Set<NSManagedObjectID> {
-    /// Filters Core Data changes by entity name
-    ///
-    /// This method allows filtering Core Data changes by entity name.
+    /// Filters Core Data changes by entity name.
     ///
     /// Example usage:
     /// ```swift
     /// // Filter changes for "GlucoseStored" entity
-    /// publisher.filteredByEntityName("GlucoseStored")
+    /// CoreDataStack.shared.entityChangePublisher.filteredByEntityName("GlucoseStored")
     /// ```
     ///
-    /// - Parameters:
-    ///   - name: The name of the Core Data entity to filter for
+    /// - Parameter name: The name of the Core Data entity to filter for
     /// - Returns: A publisher emitting filtered sets of NSManagedObjectIDs
     func filteredByEntityName(
         _ name: String
@@ -90,20 +31,3 @@ extension Publisher where Output == Set<NSManagedObjectID> {
         }
     }
 }
-
-extension Notification {
-    var insertedObjectIDs: Set<NSManagedObjectID> {
-        guard let objects = userInfo?[NSInsertedObjectsKey] as? Set<NSManagedObject> else { return [] }
-        return Set(objects.lazy.map(\.objectID))
-    }
-
-    var updatedObjectIDs: Set<NSManagedObjectID> {
-        guard let objects = userInfo?[NSUpdatedObjectsKey] as? Set<NSManagedObject> else { return [] }
-        return Set(objects.lazy.map(\.objectID))
-    }
-
-    var deletedObjectIDs: Set<NSManagedObjectID> {
-        guard let objects = userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject> else { return [] }
-        return Set(objects.lazy.map(\.objectID))
-    }
-}

+ 41 - 14
Model/CoreDataStack.swift

@@ -1,3 +1,4 @@
+import Combine
 import CoreData
 import Foundation
 import OSLog
@@ -9,11 +10,26 @@ class CoreDataStack: ObservableObject {
     private var notificationToken: NSObjectProtocol?
     private let inMemory: Bool
 
+    /// Debounces `NSPersistentStoreRemoteChange` bursts into a single history fetch + merge.
+    /// Lossless, since `fetchHistory(after: lastToken)` drains all accumulated transactions.
+    private let remoteChangeSubject = PassthroughSubject<Void, Never>()
+    private var remoteChangeCancellable: AnyCancellable?
+    private let historyQueue = DispatchQueue(label: "CoreDataStack.history", qos: .utility)
+
     let persistentContainer: NSPersistentContainer
 
     private let maxRetries = 3
     private let initializationCoordinator = CoreDataInitializationCoordinator()
 
+    /// Emits the set of changed object IDs from each batch of persistent history transactions.
+    /// Sourced from persistent history, so — unlike `NSManagedObjectContextDidSave` — it also
+    /// covers `NSBatchInsertRequest`/`NSBatchDeleteRequest` and cross-process changes. App-side
+    /// observers subscribe via `entityChangePublisher` and filter with `filteredByEntityName(_:)`.
+    private let entityChangeSubject = PassthroughSubject<Set<NSManagedObjectID>, Never>()
+    var entityChangePublisher: AnyPublisher<Set<NSManagedObjectID>, Never> {
+        entityChangeSubject.eraseToAnyPublisher()
+    }
+
     private init(inMemory: Bool = false) {
         self.inMemory = inMemory
 
@@ -103,8 +119,6 @@ class CoreDataStack: ObservableObject {
         /// - Tag: newBackgroundContext
         let taskContext = persistentContainer.newBackgroundContext()
 
-        /// ensure that the background contexts stay in sync with the main context
-        taskContext.automaticallyMergesChangesFromParent = true
         taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
         taskContext.undoManager = nil
         return taskContext
@@ -160,11 +174,19 @@ class CoreDataStack: ObservableObject {
         // Update view context with objectIDs from history change request
         /// - Tag: mergeChanges
         let viewContext = persistentContainer.viewContext
+        let changedObjectIDs = Set(history.flatMap { $0.changes ?? [] }.map(\.changedObjectID))
+
         viewContext.perform {
             for transaction in history {
                 viewContext.mergeChanges(fromContextDidSave: transaction.objectIDNotification())
                 self.lastToken = transaction.token
             }
+
+            // Notify app-side observers (services) about which objects changed. This history-sourced
+            // change feed replaces the hand-rolled changedObjectsOnManagedObjectContextDidSavePublisher.
+            if !changedObjectIDs.isEmpty {
+                self.entityChangeSubject.send(changedObjectIDs)
+            }
         }
     }
 
@@ -194,12 +216,19 @@ class CoreDataStack: ObservableObject {
             forName: .NSPersistentStoreRemoteChange,
             object: nil,
             queue: nil
-        ) { _ in
-            Task {
-                await self.fetchPersistentHistory()
-            }
+        ) { [weak self] _ in
+            // Just signal here; fetching happens in the debounced pipeline below. Notifications
+            // arrive on arbitrary threads, so serialize the sends via `historyQueue`.
+            guard let self else { return }
+            self.historyQueue.async { self.remoteChangeSubject.send() }
         }
 
+        remoteChangeCancellable = remoteChangeSubject
+            .debounce(for: .milliseconds(200), scheduler: historyQueue)
+            .sink { [weak self] in
+                Task { await self?.fetchPersistentHistory() }
+            }
+
         debug(.coreData, "Set up persistent store change notifications")
     }
 
@@ -320,7 +349,9 @@ extension CoreDataStack {
         taskContext.transactionAuthor = "batchDelete"
 
         // Get the number of days we want to keep the data
-        let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date())!
+        guard let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date()) else {
+            throw CoreDataError.validationError(function: callingFunction, file: callingClass)
+        }
 
         // Fetch all the objects that are older than the specified days
         let fetchRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: objectType))
@@ -378,7 +409,9 @@ extension CoreDataStack {
         taskContext.transactionAuthor = "batchDelete"
 
         // Get the target date
-        let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date())!
+        guard let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date()) else {
+            throw CoreDataError.validationError(function: callingFunction, file: callingClass)
+        }
 
         // Fetch Parent objects older than the target date
         let fetchParentRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: parentType))
@@ -465,9 +498,6 @@ extension CoreDataStack {
             request.resultType = .managedObjectResultType
         }
 
-        context.name = "fetchContext"
-        context.transactionAuthor = "fetchEntities"
-
         /// we need to ensure that the fetch immediately returns a value as long as the whole app does not use the async await pattern, otherwise we could perform this asynchronously with backgroundContext.perform and not block the thread
         return try context.performAndWait {
             do {
@@ -519,9 +549,6 @@ extension CoreDataStack {
             request.relationshipKeyPathsForPrefetching = prefetchKeyPaths
         }
 
-        context.name = "fetchContext"
-        context.transactionAuthor = "fetchEntities"
-
         return try await context.perform {
             do {
                 if propertiesToFetch != nil {

+ 6 - 0
Model/Helper/NSPredicates.swift

@@ -66,6 +66,12 @@ extension NSPredicate {
         return NSPredicate(format: "date >= %@", date as NSDate)
     }
 
+    /// For entities keyed on `startDate` (OverrideRunStored, TempTargetRunStored).
+    static var predicateForStartDateOneDayAgo: NSPredicate {
+        let date = Date.oneDayAgo
+        return NSPredicate(format: "startDate >= %@", date as NSDate)
+    }
+
     static var carbsHistory: NSPredicate {
         let date = Date.oneDayAgo
         return NSPredicate(format: "date >= %@ AND carbs > 0", date as NSDate)

+ 9 - 0
Model/NSFetchedResultsControllerDelegate.swift

@@ -0,0 +1,9 @@
+import CoreData
+
+final class FetchedResultsControllerDelegate: NSObject, NSFetchedResultsControllerDelegate {
+    var onContentChange: (() -> Void)?
+
+    func controllerDidChangeContent(_: NSFetchedResultsController<any NSFetchRequestResult>) {
+        onContentChange?()
+    }
+}

+ 1 - 1
Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents

@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
-<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788.4" systemVersion="25B78" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24837" systemVersion="25D125" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
     <entity name="BolusStored" representedClassName="BolusStored" syncable="YES">
         <attribute name="amount" optional="YES" attributeType="Decimal" defaultValueString="0"/>
         <attribute name="isExternal" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>

+ 14 - 0
Trio.xcodeproj/project.pbxproj

@@ -313,6 +313,9 @@
 		3E54EF2C2E476DA40006F54D /* MedtrumKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */; };
 		3E54EF2D2E476DA40006F54D /* MedtrumKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		3E62C7822F54CC1B00433237 /* BolusDisplayThreshold.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */; };
+		3E705AFB2FF94FAB0007F96B /* BolusStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E705AFA2FF94FAB0007F96B /* BolusStatus.swift */; };
+		3E84DA402F48D96000033608 /* EversenseKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E84DA3F2F48D96000033608 /* EversenseKit.framework */; };
+		3E84DA412F48D96000033608 /* EversenseKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3E84DA3F2F48D96000033608 /* EversenseKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		3EF667132FE48509009FB31A /* BasalDeliveryState+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF667122FE48502009FB31A /* BasalDeliveryState+Extension.swift */; };
 		3F23E18680094E6DA98628E4 /* QuickPickBolusesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A54068ABDAE4898B243DF14 /* QuickPickBolusesView.swift */; };
 		41740E936552456AAC0EDAC3 /* SettingsSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */; };
@@ -412,6 +415,7 @@
 		BD04ECCE2D29952A008C5FEB /* BolusProgressOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD04ECCD2D299522008C5FEB /* BolusProgressOverlay.swift */; };
 		BD0B2EF32C5998E600B3298F /* MealPresetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0B2EF22C5998E600B3298F /* MealPresetView.swift */; };
 		BD10516D2DA986E1007C6D89 /* PulsingLogoAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD10516C2DA986DC007C6D89 /* PulsingLogoAnimation.swift */; };
+		BD136FAE2F6AC55F002217F9 /* NSFetchedResultsControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD136FAD2F6AC555002217F9 /* NSFetchedResultsControllerDelegate.swift */; };
 		BD1179202F4E22C100F90001 /* TrioAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179212F4E22C100F90001 /* TrioAlertManager.swift */; };
 		BD1179222F4E22C100F90001 /* TrioModalAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */; };
 		BD1179242F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */; };
@@ -976,6 +980,7 @@
 				CE95BF642BA771BE00DC3DE3 /* LoopTestingKit.framework in Embed Frameworks */,
 				CE95BF622BA7715900DC3DE3 /* MockKitUI.framework in Embed Frameworks */,
 				3B4BA78D2D8DC0EC0069D5B8 /* ShareClientUI.framework in Embed Frameworks */,
+				3E84DA412F48D96000033608 /* EversenseKit.framework in Embed Frameworks */,
 				3B4BA7872D8DBD690069D5B8 /* RileyLinkKitUI.framework in Embed Frameworks */,
 				3B4BA78B2D8DC0EC0069D5B8 /* ShareClient.framework in Embed Frameworks */,
 				3B4BA7912D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework in Embed Frameworks */,
@@ -1321,6 +1326,8 @@
 		3BF92F392D86F1AA006B545A /* iob-error-log.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "iob-error-log.json"; sourceTree = "<group>"; };
 		3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MedtrumKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusDisplayThreshold.swift; sourceTree = "<group>"; };
+		3E705AFA2FF94FAB0007F96B /* BolusStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusStatus.swift; sourceTree = "<group>"; };
+		3E84DA3F2F48D96000033608 /* EversenseKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = EversenseKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		3EF667122FE48502009FB31A /* BasalDeliveryState+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BasalDeliveryState+Extension.swift"; sourceTree = "<group>"; };
 		3F60E97100041040446F44E7 /* PumpConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigStateModel.swift; sourceTree = "<group>"; };
 		3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigEditorDataFlow.swift; sourceTree = "<group>"; };
@@ -1421,6 +1428,7 @@
 		BD04ECCD2D299522008C5FEB /* BolusProgressOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusProgressOverlay.swift; sourceTree = "<group>"; };
 		BD0B2EF22C5998E600B3298F /* MealPresetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPresetView.swift; sourceTree = "<group>"; };
 		BD10516C2DA986DC007C6D89 /* PulsingLogoAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PulsingLogoAnimation.swift; sourceTree = "<group>"; };
+		BD136FAD2F6AC555002217F9 /* NSFetchedResultsControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSFetchedResultsControllerDelegate.swift; sourceTree = "<group>"; };
 		BD1179212F4E22C100F90001 /* TrioAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioAlertManager.swift; sourceTree = "<group>"; };
 		BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioModalAlertScheduler.swift; sourceTree = "<group>"; };
 		BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioUserNotificationAlertScheduler.swift; sourceTree = "<group>"; };
@@ -1948,6 +1956,7 @@
 				B958F1B72BA0711600484851 /* MKRingProgressView in Frameworks */,
 				3B4BA7702D8DBD690069D5B8 /* G7SensorKit.framework in Frameworks */,
 				3B4BA76C2D8DBD690069D5B8 /* CGMBLEKitUI.framework in Frameworks */,
+				3E84DA402F48D96000033608 /* EversenseKit.framework in Frameworks */,
 				CE95BF5B2BA770C300DC3DE3 /* LoopKit.framework in Frameworks */,
 				38B17B6625DD90E0005CAE3D /* SwiftDate in Frameworks */,
 				3833B46D26012030003021B3 /* Algorithms in Frameworks */,
@@ -2556,6 +2565,7 @@
 			children = (
 				B6E925122EB3932A0076D719 /* OmnipodKit.framework */,
 				3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */,
+				3E84DA3F2F48D96000033608 /* EversenseKit.framework */,
 				3B4BA7882D8DC0EC0069D5B8 /* TidepoolServiceKit.framework */,
 				3B4BA7892D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework */,
 				3B4BA75B2D8DBD690069D5B8 /* CGMBLEKit.framework */,
@@ -2738,6 +2748,7 @@
 				388358C725EEF6D200E024B2 /* BasalProfileEntry.swift */,
 				38D0B3B525EBE24900CB6E88 /* Battery.swift */,
 				382C134A25F14E3700715CE1 /* BGTargets.swift */,
+				3E705AFA2FF94FAB0007F96B /* BolusStatus.swift */,
 				3870FF4225EC13F40088248F /* BloodGlucose.swift */,
 				38A9260425F012D8009E3739 /* CarbRatios.swift */,
 				38D0B3D825EC07C400CB6E88 /* CarbsEntry.swift */,
@@ -3275,6 +3286,7 @@
 		587A54C82BCDCE0F009D38E2 /* Model */ = {
 			isa = PBXGroup;
 			children = (
+				BD136FAD2F6AC555002217F9 /* NSFetchedResultsControllerDelegate.swift */,
 				3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */,
 				BDF34F8F2C10CF8C00D51995 /* CoreDataStack.swift */,
 				BD4064D02C4ED26900582F43 /* CoreDataObserver.swift */,
@@ -5510,6 +5522,7 @@
 				BDA7593E2D37CFC400E649A4 /* CarbEntryEditorView.swift in Sources */,
 				118DF76A2C5ECBC60067FEB7 /* ApplyOverridePresetIntent.swift in Sources */,
 				58645B992CA2D1A4008AFCE7 /* GlucoseSetup.swift in Sources */,
+				BD136FAE2F6AC55F002217F9 /* NSFetchedResultsControllerDelegate.swift in Sources */,
 				7BCFACB97C821041BA43A114 /* ManualTempBasalRootView.swift in Sources */,
 				38E44534274E411700EC9A94 /* Disk+InternalHelpers.swift in Sources */,
 				DDF691052DA2CA23008BF16C /* AppDiagnosticsStateModel.swift in Sources */,
@@ -5574,6 +5587,7 @@
 				DDE1795E2C910127003CDDB7 /* PumpEventStored+CoreDataClass.swift in Sources */,
 				3BBB76AA2E01C70B0040977D /* MealCob.swift in Sources */,
 				BDDAF9EF2D00554500B34E7A /* SelectionPopoverView.swift in Sources */,
+				3E705AFB2FF94FAB0007F96B /* BolusStatus.swift in Sources */,
 				DDE1795F2C910127003CDDB7 /* PumpEventStored+CoreDataProperties.swift in Sources */,
 				DDE179602C910127003CDDB7 /* StatsData+CoreDataClass.swift in Sources */,
 				DD30BA0E2E07700000DA677C /* Profile+Autosens.swift in Sources */,

+ 3 - 0
Trio.xcworkspace/contents.xcworkspacedata

@@ -35,6 +35,9 @@
       location = "group:G7SensorKit/G7SensorKit.xcodeproj">
    </FileRef>
    <FileRef
+      location = "group:EversenseKit/EversenseKit.xcodeproj">
+   </FileRef>
+   <FileRef
       location = "group:TidepoolService/TidepoolService.xcodeproj">
    </FileRef>
 </Workspace>

+ 109 - 64
Trio/Sources/APS/APSManager.swift

@@ -113,8 +113,6 @@ final class BaseAPSManager: APSManager, Injectable {
         }
     }
 
-    let privateContext = CoreDataStack.shared.newTaskContext()
-
     private var openAPS: OpenAPS!
 
     private var lifetime = Lifetime()
@@ -213,10 +211,12 @@ final class BaseAPSManager: APSManager, Injectable {
 
         deviceDataManager.bolusTrigger
             .receive(on: processQueue)
-            .sink { [weak self] bolusing in
-                if bolusing {
+            .sink { [weak self] bolusState in
+                switch bolusState {
+                case .initiating,
+                     .inProgress:
                     self?.createBolusReporter()
-                } else {
+                case .noBolus:
                     self?.clearBolusReporter()
                 }
             }
@@ -360,14 +360,16 @@ final class BaseAPSManager: APSManager, Injectable {
     }
 
     private func calculateLoopInterval(loopStartDate: Date) async -> Double? {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "calculateLoopInterval"
         do {
-            return try await privateContext.perform { [weak self] in
+            return try await context.perform { [weak self] in
                 guard let self else { return nil }
                 let requestStats = LoopStatRecord.fetchRequest() as NSFetchRequest<LoopStatRecord>
                 let sortStats = NSSortDescriptor(key: "end", ascending: false)
                 requestStats.sortDescriptors = [sortStats]
                 requestStats.fetchLimit = 1
-                let previousLoop = try self.privateContext.fetch(requestStats)
+                let previousLoop = try context.fetch(requestStats)
 
                 if (previousLoop.first?.end ?? .distantFuture) < loopStartDate {
                     return self.roundDouble(
@@ -468,15 +470,27 @@ final class BaseAPSManager: APSManager, Injectable {
 
         try await calculateAndStoreTDD()
 
-        // Fetch glucose asynchronously
-        let glucose = try await fetchGlucose(predicate: NSPredicate.predicateForOneHourAgo, fetchLimit: 6)
-
         var invalidGlucoseError: String?
 
-        // Perform the context-related checks and actions
-        let isValidGlucoseData = await privateContext.perform { [weak self] in
+        // Fetch glucose and run validation on the same context to avoid cross-context property access.
+        let validationContext = CoreDataStack.shared.newTaskContext()
+        validationContext.name = "determineBasal.validation"
+
+        let isValidGlucoseData = await validationContext.perform { [weak self] in
             guard let self else { return false }
 
+            let glucose: [GlucoseStored]
+            do {
+                glucose = try self.fetchGlucose(
+                    on: validationContext,
+                    predicate: NSPredicate.predicateForOneHourAgo,
+                    fetchLimit: 6
+                )
+            } catch {
+                debug(.apsManager, "Failed to fetch glucose for validation: \(error)")
+                return false
+            }
+
             guard glucose.count > 2 else {
                 debug(.apsManager, "Not enough glucose data")
                 invalidGlucoseError =
@@ -697,16 +711,19 @@ final class BaseAPSManager: APSManager, Injectable {
     }
 
     private func fetchCurrentTempBasal(date: Date) async throws -> TempBasal {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchCurrentTempBasal"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
-            onContext: privateContext,
+            onContext: context,
             predicate: NSPredicate.recentPumpHistory,
             key: "timestamp",
             ascending: false,
-            fetchLimit: 1
+            fetchLimit: 1,
+            relationshipKeyPathsForPrefetching: ["tempBasal"]
         )
 
-        let fetchedTempBasal = await privateContext.perform {
+        let fetchedTempBasal = await context.perform {
             guard let fetchedResults = results as? [PumpEventStored],
                   let tempBasalEvent = fetchedResults.first,
                   let tempBasal = tempBasalEvent.tempBasal,
@@ -771,9 +788,11 @@ final class BaseAPSManager: APSManager, Injectable {
     private func setValues(determinationID: NSManagedObjectID) async throws
         -> (NSDecimalNumber?, TimeInterval?, NSDecimalNumber?)
     {
-        return try await privateContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "setValues"
+        return try await context.perform {
             do {
-                let determination = try self.privateContext.existingObject(with: determinationID) as? OrefDetermination
+                let determination = try context.existingObject(with: determinationID) as? OrefDetermination
 
                 let rate = determination?.rate
                 let duration = determination?.duration.flatMap { TimeInterval(truncating: $0) * 60 }
@@ -804,8 +823,10 @@ final class BaseAPSManager: APSManager, Injectable {
                 return
             }
 
-            try await privateContext.perform {
-                guard let determinationUpdated = try self.privateContext
+            let context = CoreDataStack.shared.newTaskContext()
+            context.name = "reportEnacted"
+            try await context.perform {
+                guard let determinationUpdated = try context
                     .existingObject(with: determinationID) as? OrefDetermination
                 else {
                     debug(.apsManager, "Could not find determination object in context")
@@ -816,8 +837,8 @@ final class BaseAPSManager: APSManager, Injectable {
                 determinationUpdated.enacted = wasEnacted
                 determinationUpdated.isUploadedToNS = false
 
-                guard self.privateContext.hasChanges else { return }
-                try self.privateContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
                 debug(.apsManager, "Determination enacted. Enacted: \(wasEnacted)")
             }
         } catch {
@@ -864,8 +885,8 @@ final class BaseAPSManager: APSManager, Injectable {
         return Double(sorted[length / 2])
     }
 
-    /// Computes Time-in-Range statistics. Must be called from inside a
-    /// `privateContext.perform` block — the inner perform was redundant
+    /// Computes Time-in-Range statistics. Must be called from within a
+    /// `perform`/`performAndWait` block on the context that owns `glucose`.
     private func tir(_ glucose: [GlucoseStored]) -> (TIR: Double, hypos: Double, hypers: Double, normal_: Double) {
         let justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
         let totalReadings = justGlucoseArray.count
@@ -983,25 +1004,26 @@ final class BaseAPSManager: APSManager, Injectable {
         return output
     }
 
-    // fetch glucose for time interval
-    func fetchGlucose(predicate: NSPredicate, fetchLimit: Int? = nil, batchSize: Int? = nil) async throws -> [GlucoseStored] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+    /// Synchronously fetches glucose on the given context. Must be called from within a `perform`/`performAndWait` block of that context
+    func fetchGlucose(
+        on context: NSManagedObjectContext,
+        predicate: NSPredicate,
+        fetchLimit: Int? = nil,
+        batchSize: Int? = nil
+    ) throws -> [GlucoseStored] {
+        let results = try CoreDataStack.shared.fetchEntities(
             ofType: GlucoseStored.self,
-            onContext: privateContext,
+            onContext: context,
             predicate: predicate,
             key: "date",
             ascending: false,
             fetchLimit: fetchLimit,
             batchSize: batchSize
         )
-
-        return try await privateContext.perform {
-            guard let glucoseResults = results as? [GlucoseStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
-
-            return glucoseResults
+        guard let glucoseResults = results as? [GlucoseStored] else {
+            throw CoreDataError.fetchError(function: #function, file: #file)
         }
+        return glucoseResults
     }
 
     private func lastLoopForStats() async -> Date? {
@@ -1010,9 +1032,11 @@ final class BaseAPSManager: APSManager, Injectable {
         requestStats.sortDescriptors = [sortStats]
         requestStats.fetchLimit = 1
 
-        return await privateContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "lastLoopForStats"
+        return await context.perform {
             do {
-                return try self.privateContext.fetch(requestStats).first?.lastrun
+                return try context.fetch(requestStats).first?.lastrun
             } catch {
                 print(error.localizedDescription)
                 return .distantPast
@@ -1029,9 +1053,11 @@ final class BaseAPSManager: APSManager, Injectable {
         let sortLSR = NSSortDescriptor(key: "start", ascending: false)
         requestLSR.sortDescriptors = [sortLSR]
 
-        return await privateContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "loopStats"
+        return await context.perform {
             do {
-                let lsr = try self.privateContext.fetch(requestLSR)
+                let lsr = try context.fetch(requestLSR)
 
                 // Compute LoopStats for 24 hours
                 let oneDayLoops = self.loops(lsr)
@@ -1081,23 +1107,36 @@ final class BaseAPSManager: APSManager, Injectable {
         hbs: Durations,
         variance: Variance
     )? {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "glucoseForStats"
         do {
-            // Get the Glucose Values
-            let glucose24h = try await fetchGlucose(predicate: NSPredicate.predicateForOneDayAgo, fetchLimit: 288, batchSize: 50)
-            let glucoseOneWeek = try await fetchGlucose(
-                predicate: NSPredicate.predicateForOneWeek,
-                batchSize: 250
-            )
-            let glucoseOneMonth = try await fetchGlucose(
-                predicate: NSPredicate.predicateForOneMonth,
-                batchSize: 500
-            )
-            let glucoseThreeMonths = try await fetchGlucose(
-                predicate: NSPredicate.predicateForThreeMonths,
-                batchSize: 1000
-            )
+            return try await context.perform {
+                // Fetch all windows on the same context so subsequent property access is safe.
+                let glucose24h = try self.fetchGlucose(
+                    on: context,
+                    predicate: NSPredicate.predicateForOneDayAgo,
+                    fetchLimit: 288,
+                    batchSize: 50
+                )
+                let glucoseOneWeek = try self.fetchGlucose(
+                    on: context,
+                    predicate: NSPredicate.predicateForOneWeek,
+                    fetchLimit: 288 * 7,
+                    batchSize: 250
+                )
+                let glucoseOneMonth = try self.fetchGlucose(
+                    on: context,
+                    predicate: NSPredicate.predicateForOneMonth,
+                    fetchLimit: 288 * 7 * 30,
+                    batchSize: 500
+                )
+                let glucoseThreeMonths = try self.fetchGlucose(
+                    on: context,
+                    predicate: NSPredicate.predicateForThreeMonths,
+                    fetchLimit: 288 * 7 * 30 * 3,
+                    batchSize: 1000
+                )
 
-            return await privateContext.perform {
                 let units = self.settingsManager.settings.units
 
                 // First date
@@ -1219,9 +1258,10 @@ final class BaseAPSManager: APSManager, Injectable {
     }
 
     private func loopStats(loopStatRecord: LoopStats) {
-        privateContext.perform { [weak self] in
-            guard let self else { return }
-            let nLS = LoopStatRecord(context: self.privateContext)
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "loopStats.save"
+        context.perform {
+            let nLS = LoopStatRecord(context: context)
             nLS.start = loopStatRecord.start
             nLS.end = loopStatRecord.end ?? Date()
             nLS.loopStatus = loopStatRecord.loopStatus
@@ -1229,8 +1269,8 @@ final class BaseAPSManager: APSManager, Injectable {
             nLS.interval = loopStatRecord.interval ?? 0.0
 
             do {
-                guard self.privateContext.hasChanges else { return }
-                try self.privateContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch {
                 print(error.localizedDescription)
             }
@@ -1347,6 +1387,10 @@ final class BaseAPSManager: APSManager, Injectable {
     /// Mutations are dispatched onto the queue regardless, so a future
     /// caller from another context (e.g. `cancelBolus`) stays safe.
     private func createBolusReporter() {
+        if bolusReporter != nil {
+            return
+        }
+
         processQueue.async {
             self.bolusReporter = self.pumpManager?.createBolusProgressReporter(reportingOn: self.processQueue)
             self.bolusReporter?.addObserver(self)
@@ -1437,8 +1481,9 @@ extension BaseAPSManager: PumpManagerStatusObserver {
     func pumpManager(_: PumpManager, didUpdate status: PumpManagerStatus, oldStatus _: PumpManagerStatus) {
         let percent = Int((status.pumpBatteryChargeRemaining ?? 1) * 100)
 
-        privateContext.perform { [weak self] in
-            guard let self else { return }
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "storeBatteryStatus"
+        context.perform {
             /// only update the last item with the current battery infos instead of saving a new one each time
             let fetchRequest: NSFetchRequest<OpenAPS_Battery> = OpenAPS_Battery.fetchRequest()
             fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
@@ -1446,13 +1491,13 @@ extension BaseAPSManager: PumpManagerStatusObserver {
             fetchRequest.fetchLimit = 1
 
             do {
-                let results = try self.privateContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 let batteryToStore: OpenAPS_Battery
 
                 if let existingBattery = results.first {
                     batteryToStore = existingBattery
                 } else {
-                    batteryToStore = OpenAPS_Battery(context: self.privateContext)
+                    batteryToStore = OpenAPS_Battery(context: context)
                     batteryToStore.id = UUID()
                 }
 
@@ -1462,8 +1507,8 @@ extension BaseAPSManager: PumpManagerStatusObserver {
                 batteryToStore.status = percent > 10 ? "normal" : "low"
                 batteryToStore.display = status.pumpBatteryChargeRemaining != nil
 
-                guard self.privateContext.hasChanges else { return }
-                try self.privateContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch {
                 debug(.apsManager, "Failed to fetch or save battery: \(error)")
             }

+ 22 - 16
Trio/Sources/APS/DeviceDataManager.swift

@@ -21,7 +21,7 @@ protocol DeviceDataManager: GlucoseSource {
     var loopInProgress: Bool { get set }
     var pumpDisplayState: CurrentValueSubject<PumpDisplayState?, Never> { get }
     var recommendsLoop: PassthroughSubject<Void, Never> { get }
-    var bolusTrigger: PassthroughSubject<Bool, Never> { get }
+    var bolusTrigger: CurrentValueSubject<BolusStatus, Never> { get }
     var manualTempBasal: PassthroughSubject<Bool, Never> { get }
     var scheduledBasal: PassthroughSubject<Bool?, Never> { get }
     var suspended: PassthroughSubject<Bool, Never> { get }
@@ -69,7 +69,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
         .distantPast
 
     let recommendsLoop = PassthroughSubject<Void, Never>()
-    let bolusTrigger = PassthroughSubject<Bool, Never>()
+    let bolusTrigger = CurrentValueSubject<BolusStatus, Never>(.noBolus)
     let errorSubject = PassthroughSubject<Error, Never>()
     let pumpNewStatus = PassthroughSubject<Void, Never>()
     let manualTempBasal = PassthroughSubject<Bool, Never>()
@@ -80,7 +80,6 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
     @SyncAccess private var pumpUpdateCancellable: AnyCancellable?
     private var pumpUpdatePromise: Future<Bool, Never>.Promise?
     @SyncAccess var loopInProgress: Bool = false
-    private let privateContext = CoreDataStack.shared.newTaskContext()
 
     var pumpManager: PumpManagerUI? {
         didSet {
@@ -174,8 +173,10 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
                         display: simulatorPump.state.pumpBatteryChargeRemaining != nil
                     )
                     Task {
-                        await self.privateContext.perform {
-                            let saveBatteryToCoreData = OpenAPS_Battery(context: self.privateContext)
+                        let context = CoreDataStack.shared.newTaskContext()
+                        context.name = "storeSimulatorBattery"
+                        await context.perform {
+                            let saveBatteryToCoreData = OpenAPS_Battery(context: context)
                             saveBatteryToCoreData.id = UUID()
                             saveBatteryToCoreData.date = Date()
                             saveBatteryToCoreData.percent = Double(batteryPercent)
@@ -185,8 +186,8 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
                             saveBatteryToCoreData.display = simulatorPump.state.pumpBatteryChargeRemaining != nil
 
                             do {
-                                guard self.privateContext.hasChanges else { return }
-                                try self.privateContext.save()
+                                guard context.hasChanges else { return }
+                                try context.save()
                             } catch {
                                 print(error.localizedDescription)
                             }
@@ -209,18 +210,20 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
                 storage.save(modifiedPreferences, as: OpenAPS.Settings.preferences)
                 // Remove OpenAPS_Battery entries
                 Task {
-                    await self.privateContext.perform {
+                    let context = CoreDataStack.shared.newTaskContext()
+                    context.name = "deleteBatteryEntries"
+                    await context.perform {
                         let fetchRequest: NSFetchRequest<OpenAPS_Battery> = OpenAPS_Battery.fetchRequest()
 
                         do {
-                            let batteryEntries = try self.privateContext.fetch(fetchRequest)
+                            let batteryEntries = try context.fetch(fetchRequest)
 
                             for entry in batteryEntries {
-                                self.privateContext.delete(entry)
+                                context.delete(entry)
                             }
 
-                            guard self.privateContext.hasChanges else { return }
-                            try self.privateContext.save()
+                            guard context.hasChanges else { return }
+                            try context.save()
 
                         } catch {
                             debug(.deviceManager, "Failed to delete OpenAPS_Battery entries: \(error)")
@@ -470,10 +473,13 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
         debug(.deviceManager, "New pump status Bolus: \(status.bolusState)")
         debug(.deviceManager, "New pump status Basal: \(String(describing: status.basalDeliveryState))")
 
-        if case .inProgress = status.bolusState {
-            bolusTrigger.send(true)
-        } else {
-            bolusTrigger.send(false)
+        switch status.bolusState {
+        case .initiating:
+            bolusTrigger.send(.initiating)
+        case let .inProgress(dose):
+            bolusTrigger.send(.inProgress)
+        default:
+            bolusTrigger.send(.noBolus)
         }
 
         switch status.basalDeliveryState {

+ 6 - 4
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -63,8 +63,6 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
 
     private lazy var simulatorSource = GlucoseSimulatorSource()
 
-    private let context = CoreDataStack.shared.newTaskContext()
-
     /// Enforce mutual exclusion on calls to glucoseStoreAndHeartDecision
     private let glucoseStoreAndHeartLock = DispatchSemaphore(value: 1)
 
@@ -305,7 +303,9 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         try await glucoseStorage.storeGlucose(filtered)
 
         if settingsManager.settings.smoothGlucose {
-            await exponentialSmoothingGlucose(context: context)
+            let smoothingContext = CoreDataStack.shared.newTaskContext()
+            smoothingContext.name = "exponentialSmoothingGlucose"
+            await exponentialSmoothingGlucose(context: smoothingContext)
         }
 
         deviceDataManager.heartbeat(date: Date())
@@ -382,7 +382,9 @@ extension BaseFetchGlucoseManager: SettingsObserver {
 
             self.glucoseStoreAndHeartLock.wait()
             Task {
-                await self.exponentialSmoothingGlucose(context: self.context)
+                let context = CoreDataStack.shared.newTaskContext()
+                context.name = "exponentialSmoothingGlucose"
+                await self.exponentialSmoothingGlucose(context: context)
                 self.glucoseStoreAndHeartLock.signal()
             }
         }

+ 0 - 1
Trio/Sources/APS/FetchTreatmentsManager.swift

@@ -14,7 +14,6 @@ final class BaseFetchTreatmentsManager: FetchTreatmentsManager, Injectable {
 
     private var lifetime = Lifetime()
     private let timer = DispatchTimer(timeInterval: 1.minutes.timeInterval)
-    private var backgroundContext = CoreDataStack.shared.newTaskContext()
 
     init(resolver: Resolver) {
         injectServices(resolver)

+ 58 - 37
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -10,10 +10,14 @@ final class OpenAPS {
     private let storage: FileStorage
     private let tddStorage: TDDStorage
 
-    let context = CoreDataStack.shared.newTaskContext()
-
     let jsonConverter = JSONConverter()
 
+    private func newContext(_ name: String) -> NSManagedObjectContext {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = name
+        return context
+    }
+
     init(storage: FileStorage, tddStorage: TDDStorage) {
         self.storage = storage
         self.tddStorage = tddStorage
@@ -32,9 +36,9 @@ final class OpenAPS {
     }
 
     // Use the helper function for cleaner code
-    func processDetermination(_ determination: Determination) async {
+    func processDetermination(_ determination: Determination, on context: NSManagedObjectContext) async {
         await context.perform {
-            let newOrefDetermination = OrefDetermination(context: self.context)
+            let newOrefDetermination = OrefDetermination(context: context)
             newOrefDetermination.id = UUID()
             newOrefDetermination.insulinSensitivity = self.decimalToNSDecimalNumber(determination.isf)
             newOrefDetermination.currentTarget = self.decimalToNSDecimalNumber(determination.current_target)
@@ -62,14 +66,14 @@ final class OpenAPS {
                 ["iob": predictions.iob, "zt": predictions.zt, "cob": predictions.cob, "uam": predictions.uam]
                     .forEach { type, values in
                         if let values = values {
-                            let forecast = Forecast(context: self.context)
+                            let forecast = Forecast(context: context)
                             forecast.id = UUID()
                             forecast.type = type
                             forecast.date = Date()
                             forecast.orefDetermination = newOrefDetermination
 
                             for (index, value) in values.enumerated() {
-                                let forecastValue = ForecastValue(context: self.context)
+                                let forecastValue = ForecastValue(context: context)
                                 forecastValue.index = Int32(index)
                                 forecastValue.value = Int32(value)
                                 forecast.addToForecastValues(forecastValue)
@@ -81,14 +85,14 @@ final class OpenAPS {
         }
 
         // First save the current Determination to Core Data
-        await attemptToSaveContext()
+        await attemptToSaveContext(on: context)
     }
 
-    func attemptToSaveContext() async {
+    func attemptToSaveContext(on context: NSManagedObjectContext) async {
         await context.perform {
             do {
-                guard self.context.hasChanges else { return }
-                try self.context.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch {
                 debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save Determination to Core Data")
             }
@@ -161,7 +165,11 @@ final class OpenAPS {
         return jsonConverter.convertToJSON(algorithmGlucose)
     }
 
-    private func fetchAndProcessCarbs(additionalCarbs: Decimal? = nil, carbsDate: Date? = nil) async throws -> String {
+    private func fetchAndProcessCarbs(
+        on context: NSManagedObjectContext,
+        additionalCarbs: Decimal? = nil,
+        carbsDate: Date? = nil
+    ) async throws -> String {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             onContext: context,
@@ -213,14 +221,15 @@ final class OpenAPS {
         return json
     }
 
-    private func fetchPumpHistoryObjectIDs() async throws -> [NSManagedObjectID]? {
+    private func fetchPumpHistoryObjectIDs(on context: NSManagedObjectContext) async throws -> [NSManagedObjectID]? {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             onContext: context,
             predicate: NSPredicate.pumpHistoryLast1440Minutes,
             key: "timestamp",
             ascending: false,
-            batchSize: 50
+            batchSize: 50,
+            relationshipKeyPathsForPrefetching: ["bolus", "tempBasal"]
         )
 
         return try await context.perform {
@@ -233,6 +242,7 @@ final class OpenAPS {
     }
 
     private func parsePumpHistory(
+        on context: NSManagedObjectContext,
         _ pumpHistoryObjectIDs: [NSManagedObjectID],
         simulatedBolusAmount: Decimal? = nil
     ) async throws -> String {
@@ -245,12 +255,12 @@ final class OpenAPS {
         // the oldest event in pump history can be a resume with no preceding pump
         // activity. oref interprets this as the end of a suspend that never started,
         // which drives negative IOB and can cause excessive insulin delivery.
-        let orphanedResumes = try await fetchOrphanedResumes()
+        let orphanedResumes = try await fetchOrphanedResumes(on: context)
 
         // Execute all operations on the background context
         return await context.perform {
             // Load and map pump events to DTOs
-            var dtos = self.loadAndMapPumpEvents(pumpHistoryObjectIDs, orphanedResumes: orphanedResumes)
+            var dtos = self.loadAndMapPumpEvents(pumpHistoryObjectIDs, orphanedResumes: orphanedResumes, on: context)
 
             // Optionally add the IOB as a DTO
             if let simulatedBolusAmount = simulatedBolusAmount {
@@ -265,7 +275,8 @@ final class OpenAPS {
 
     private func loadAndMapPumpEvents(
         _ pumpHistoryObjectIDs: [NSManagedObjectID],
-        orphanedResumes: [NSManagedObjectID]
+        orphanedResumes: [NSManagedObjectID],
+        on context: NSManagedObjectContext
     ) -> [PumpEventDTO] {
         OpenAPS.loadAndMapPumpEvents(pumpHistoryObjectIDs, orphanedResumes: orphanedResumes, from: context)
     }
@@ -333,7 +344,7 @@ final class OpenAPS {
     }
 
     /// Detects a cold-start orphaned resume: returns the resume's object ID if it's an orphaned resume
-    private func fetchOrphanedResumes() async throws -> [NSManagedObjectID] {
+    private func fetchOrphanedResumes(on context: NSManagedObjectContext) async throws -> [NSManagedObjectID] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             onContext: context,
@@ -394,12 +405,18 @@ final class OpenAPS {
     ) async throws -> Determination? {
         debug(.openAPS, "Start determineBasal")
 
+        let context = newContext("determineBasal")
+
         // temp_basal
         let tempBasal = currentTemp.rawJSON
 
         // Perform asynchronous calls in parallel
-        async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs() ?? []
-        async let carbs = fetchAndProcessCarbs(additionalCarbs: simulatedCarbsAmount ?? 0, carbsDate: simulatedCarbsDate)
+        async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs(on: context) ?? []
+        async let carbs = fetchAndProcessCarbs(
+            on: context,
+            additionalCarbs: simulatedCarbsAmount ?? 0,
+            carbsDate: simulatedCarbsDate
+        )
 
         var preferences = await storage.retrieveAsync(OpenAPS.Settings.preferences, as: Preferences.self) ?? Preferences()
         let glucoseFetchHours = preferences.maxMealAbsorptionTime + 0.5 // MMAT + half hour buffer
@@ -410,7 +427,7 @@ final class OpenAPS {
             fetchHours: glucoseFetchHours
         )
 
-        async let prepareTrioCustomOrefVariables = prepareTrioCustomOrefVariables()
+        async let prepareTrioCustomOrefVariables = prepareTrioCustomOrefVariables(on: context)
         async let profileAsync = loadFileFromStorageAsync(name: Settings.profile)
         async let basalAsync = loadFileFromStorageAsync(name: Settings.basalProfile)
         async let autosenseAsync = loadFileFromStorageAsync(name: Settings.autosense)
@@ -429,7 +446,7 @@ final class OpenAPS {
             reservoir,
             hasSufficientTdd
         ) = await (
-            try parsePumpHistory(await pumpHistoryObjectIDs, simulatedBolusAmount: simulatedBolusAmount),
+            try parsePumpHistory(on: context, await pumpHistoryObjectIDs, simulatedBolusAmount: simulatedBolusAmount),
             try carbs,
             try glucose,
             try prepareTrioCustomOrefVariables,
@@ -497,7 +514,7 @@ final class OpenAPS {
 
             if !simulation {
                 // save to core data asynchronously
-                await processDetermination(determination)
+                await processDetermination(determination, on: context)
             }
 
             return determination
@@ -510,7 +527,7 @@ final class OpenAPS {
         }
     }
 
-    func prepareTrioCustomOrefVariables() async throws -> RawJSON {
+    func prepareTrioCustomOrefVariables(on context: NSManagedObjectContext) async throws -> RawJSON {
         try await context.perform {
             // Retrieve user preferences
             let userPreferences = self.storage.retrieve(OpenAPS.Settings.preferences, as: Preferences.self)
@@ -521,10 +538,10 @@ final class OpenAPS {
             // Fetch historical events for Total Daily Dose (TDD) calculation
             let tenDaysAgo = Date().addingTimeInterval(-10.days.timeInterval)
             let twoHoursAgo = Date().addingTimeInterval(-2.hours.timeInterval)
-            let historicalTDDData = try self.fetchHistoricalTDDData(from: tenDaysAgo)
+            let historicalTDDData = try self.fetchHistoricalTDDData(from: tenDaysAgo, on: context)
 
             // Fetch the last active Override
-            let activeOverrides = try self.fetchActiveOverrides()
+            let activeOverrides = try self.fetchActiveOverrides(on: context)
             let isOverrideActive = activeOverrides.first?.enabled ?? false
             let overridePercentage = Decimal(activeOverrides.first?.percentage ?? 100)
             let isOverrideIndefinite = activeOverrides.first?.indefinite ?? true
@@ -546,7 +563,7 @@ final class OpenAPS {
             let averageTDDLastTenDays = totalTDD / Decimal(totalDaysCount)
             let weightedTDD = weightPercentage * averageTDDLastTwoHours + (1 - weightPercentage) * averageTDDLastTenDays
 
-            let glucose = try self.fetchGlucose()
+            let glucose = try self.fetchGlucose(on: context)
 
             // Prepare Trio's custom oref variables
             let trioCustomOrefVariablesData = TrioCustomOrefVariables(
@@ -581,9 +598,11 @@ final class OpenAPS {
     func autosense(shouldSmoothGlucose: Bool, useJavascriptOref: Bool) async throws -> Autosens? {
         debug(.openAPS, "Start autosens")
 
+        let context = newContext("autosense")
+
         // Perform asynchronous calls in parallel
-        async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs() ?? []
-        async let carbs = fetchAndProcessCarbs()
+        async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs(on: context) ?? []
+        async let carbs = fetchAndProcessCarbs(on: context)
         async let glucose = fetchAndProcessGlucose(context: context, shouldSmoothGlucose: shouldSmoothGlucose, fetchLimit: nil)
         async let getProfile = loadFileFromStorageAsync(name: Settings.profile)
         async let getBasalProfile = loadFileFromStorageAsync(name: Settings.basalProfile)
@@ -591,7 +610,7 @@ final class OpenAPS {
 
         // Await the results of asynchronous tasks
         let (pumpHistoryJSON, carbsAsJSON, glucoseAsJSON, profile, basalProfile, tempTargets) = await (
-            try parsePumpHistory(await pumpHistoryObjectIDs),
+            try parsePumpHistory(on: context, await pumpHistoryObjectIDs),
             try carbs,
             try glucose,
             getProfile,
@@ -651,9 +670,10 @@ final class OpenAPS {
         var adjustedPreferences = preferences
 
         // Check for active Temp Targets and adjust HBT if necessary
+        let context = newContext("createProfiles")
         try await context.perform {
             // Check if a Temp Target is active and check HBT differs from setting and adjust
-            if let activeTempTarget = try self.fetchActiveTempTargets().first,
+            if let activeTempTarget = try self.fetchActiveTempTargets(on: context).first,
                activeTempTarget.enabled,
                let targetValue = activeTempTarget.target?.decimalValue
             {
@@ -1140,15 +1160,16 @@ final class OpenAPS {
 
     func processAndSave(forecastData: [String: [Int]]) {
         let currentDate = Date()
+        let context = newContext("processAndSave")
 
         context.perform {
             for (type, values) in forecastData {
-                self.createForecast(type: type, values: values, date: currentDate, context: self.context)
+                self.createForecast(type: type, values: values, date: currentDate, context: context)
             }
 
             do {
-                guard self.context.hasChanges else { return }
-                try self.context.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch {
                 print(error.localizedDescription)
             }
@@ -1172,7 +1193,7 @@ final class OpenAPS {
 
 // Non-Async fetch methods for trio_custom_oref_variables
 extension OpenAPS {
-    func fetchActiveTempTargets() throws -> [TempTargetStored] {
+    func fetchActiveTempTargets(on context: NSManagedObjectContext) throws -> [TempTargetStored] {
         try CoreDataStack.shared.fetchEntities(
             ofType: TempTargetStored.self,
             onContext: context,
@@ -1183,7 +1204,7 @@ extension OpenAPS {
         ) as? [TempTargetStored] ?? []
     }
 
-    func fetchActiveOverrides() throws -> [OverrideStored] {
+    func fetchActiveOverrides(on context: NSManagedObjectContext) throws -> [OverrideStored] {
         try CoreDataStack.shared.fetchEntities(
             ofType: OverrideStored.self,
             onContext: context,
@@ -1194,7 +1215,7 @@ extension OpenAPS {
         ) as? [OverrideStored] ?? []
     }
 
-    func fetchHistoricalTDDData(from date: Date) throws -> [[String: Any]] {
+    func fetchHistoricalTDDData(from date: Date, on context: NSManagedObjectContext) throws -> [[String: Any]] {
         try CoreDataStack.shared.fetchEntities(
             ofType: TDDStored.self,
             onContext: context,
@@ -1205,7 +1226,7 @@ extension OpenAPS {
         ) as? [[String: Any]] ?? []
     }
 
-    func fetchGlucose() throws -> [GlucoseStored] {
+    func fetchGlucose(on context: NSManagedObjectContext) throws -> [GlucoseStored] {
         let results = try CoreDataStack.shared.fetchEntities(
             ofType: GlucoseStored.self,
             onContext: context,

+ 1 - 1
Trio/Sources/APS/OpenAPSSwift/Profile/Carbs.swift

@@ -19,7 +19,7 @@ struct Carbs {
         }
 
         // Check for invalid values
-        if currentRatio < 3 || currentRatio > 150 {
+        if currentRatio < 1 || currentRatio > 150 {
             warning(.openAPS, "Warning: carbRatio of \(currentRatio) out of bounds.")
             return nil
         }

+ 6 - 0
Trio/Sources/APS/PluginManager.swift

@@ -1,4 +1,5 @@
 import CGMBLEKit
+import EversenseKit
 import Foundation
 import G7SensorKit
 import G7SensorKitUI
@@ -40,6 +41,11 @@ class BasePluginManager: Injectable, PluginManager {
             pluginIdentifier: LibreTransmitterManagerV3.pluginIdentifier,
             localizedTitle: String(localized: "FreeStyle Libre"),
             manager: LibreTransmitterManagerV3.self
+        ),
+        CgmPluginDescription(
+            pluginIdentifier: EversenseCGMManager.pluginIdentifier,
+            localizedTitle: String(localized: "Eversense"),
+            manager: EversenseCGMManager.self
         )
     ]
 

+ 29 - 20
Trio/Sources/APS/Storage/CarbsStorage.swift

@@ -33,10 +33,10 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         updateSubject.eraseToAnyPublisher()
     }
 
-    private let context: NSManagedObjectContext
+    private let makeContext: () -> NSManagedObjectContext
 
-    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
-        self.context = context ?? CoreDataStack.shared.newTaskContext()
+    init(resolver: Resolver, contextProvider: (() -> NSManagedObjectContext)? = nil) {
+        makeContext = contextProvider ?? { CoreDataStack.shared.newTaskContext() }
         injectServices(resolver)
     }
 
@@ -74,6 +74,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
 
     private func filterRemoteEntries(entries: [CarbsEntry]) async throws -> [CarbsEntry] {
+        let context = makeContext()
+        context.name = "filterRemoteEntries"
         // Fetch only the date property from Core Data
         guard let existing24hCarbEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
@@ -280,8 +282,10 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     private func saveCarbsToCoreData(entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
         guard let entry = entries.last else { return }
 
+        let context = makeContext()
+        context.name = "saveCarbsToCoreData"
         await context.perform {
-            let newItem = CarbEntryStored(context: self.context)
+            let newItem = CarbEntryStored(context: context)
             newItem.date = entry.actualDate ?? entry.createdAt
             newItem.carbs = Double(truncating: NSDecimalNumber(decimal: entry.carbs))
             newItem.fat = Double(truncating: NSDecimalNumber(decimal: entry.fat ?? 0))
@@ -298,8 +302,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             }
 
             do {
-                guard self.context.hasChanges else { return }
-                try self.context.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch {
                 print(error.localizedDescription)
             }
@@ -327,9 +331,11 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             // do NOT set Health and Tidepool flags to ensure they will NOT be uploaded
             return false // return false to continue
         }
+        let context = makeContext()
+        context.name = "saveFPUToCoreDataAsBatchInsert"
         await context.perform {
             do {
-                try self.context.execute(batchInsert)
+                try context.execute(batchInsert)
                 debugPrint("Carbs Storage: \(DebuggingIdentifiers.succeeded) saved fpus to core data")
 
                 // Notify subscriber in Home State Model to update the FPU Array
@@ -345,19 +351,14 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
 
     func deleteCarbsEntryStored(_ treatmentObjectID: NSManagedObjectID) async {
-        // Use injected context if available, otherwise create new task context
-        let taskContext = context != CoreDataStack.shared.newTaskContext()
-            ? context
-            : CoreDataStack.shared.newTaskContext()
-
-        taskContext.name = "deleteContext"
-        taskContext.transactionAuthor = "deleteCarbs"
+        let context = makeContext()
+        context.name = "deleteCarbsEntryStored"
 
         var carbEntryFromCoreData: CarbEntryStored?
 
-        await taskContext.perform {
+        await context.perform {
             do {
-                carbEntryFromCoreData = try taskContext.existingObject(with: treatmentObjectID) as? CarbEntryStored
+                carbEntryFromCoreData = try context.existingObject(with: treatmentObjectID) as? CarbEntryStored
                 guard let carbEntry = carbEntryFromCoreData else {
                     debugPrint("Carb entry for batch delete not found. \(DebuggingIdentifiers.failed)")
                     return
@@ -377,7 +378,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
                     deleteRequest.resultType = .resultTypeCount
 
                     // execute the batch delete request
-                    let result = try taskContext.execute(deleteRequest) as? NSBatchDeleteResult
+                    let result = try context.execute(deleteRequest) as? NSBatchDeleteResult
                     debugPrint("\(DebuggingIdentifiers.succeeded) Deleted \(result?.result ?? 0) items with FpuID \(fpuID)")
 
                     // Notifiy subscribers of the batch delete
@@ -386,10 +387,10 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
                 // entry has no fpuID
                 // => it's a carb-only entry. use its ID to for deletion
                 else {
-                    taskContext.delete(carbEntry)
+                    context.delete(carbEntry)
 
-                    guard taskContext.hasChanges else { return }
-                    try taskContext.save()
+                    guard context.hasChanges else { return }
+                    try context.save()
 
                     debugPrint(
                         "CarbsStorage: \(#function) \(DebuggingIdentifiers.succeeded) deleted carb entry from core data"
@@ -403,6 +404,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
 
     func getCarbsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let context = makeContext()
+        context.name = "getCarbsNotYetUploadedToNightscout"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             onContext: context,
@@ -442,6 +445,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
 
     func getFPUsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let context = makeContext()
+        context.name = "getFPUsNotYetUploadedToNightscout"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             onContext: context,
@@ -481,6 +486,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
 
     func getCarbsNotYetUploadedToHealth() async throws -> [CarbsEntry] {
+        let context = makeContext()
+        context.name = "getCarbsNotYetUploadedToHealth"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             onContext: context,
@@ -512,6 +519,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
 
     func getCarbsNotYetUploadedToTidepool() async throws -> [CarbsEntry] {
+        let context = makeContext()
+        context.name = "getCarbsNotYetUploadedToTidepool"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             onContext: context,

+ 19 - 12
Trio/Sources/APS/Storage/ContactImageStorage.swift

@@ -13,9 +13,10 @@ protocol ContactImageStorage {
 final class BaseContactImageStorage: ContactImageStorage, Injectable {
     @Injected() private var settingsManager: SettingsManager!
 
-    private let backgroundContext = CoreDataStack.shared.newTaskContext()
+    private let makeContext: () -> NSManagedObjectContext
 
-    init(resolver: Resolver) {
+    init(resolver: Resolver, contextProvider: (() -> NSManagedObjectContext)? = nil) {
+        makeContext = contextProvider ?? { CoreDataStack.shared.newTaskContext() }
         injectServices(resolver)
     }
 
@@ -26,16 +27,18 @@ final class BaseContactImageStorage: ContactImageStorage, Injectable {
     ///
     /// - Returns: An array of `ContactImageEntry` objects.
     func fetchContactImageEntries() async -> [ContactImageEntry] {
+        let context = makeContext()
+        context.name = "fetchContactImageEntries"
         do {
             let results = try await CoreDataStack.shared.fetchEntitiesAsync(
                 ofType: ContactImageEntryStored.self,
-                onContext: backgroundContext,
+                onContext: context,
                 predicate: NSPredicate.all,
                 key: "hasHighContrast",
                 ascending: false
             )
 
-            return try await backgroundContext.perform {
+            return try await context.perform {
                 guard let fetchedContactImageEntries = results as? [ContactImageEntryStored]
                 else { throw CoreDataError.fetchError(function: #function, file: #file)
                 }
@@ -75,8 +78,10 @@ final class BaseContactImageStorage: ContactImageStorage, Injectable {
     ///
     /// - Parameter contactImageEntry: The `ContactImageEntry` object to be stored.
     func storeContactImageEntry(_ contactImageEntry: ContactImageEntry) async {
-        await backgroundContext.perform {
-            let newContactImageEntry = ContactImageEntryStored(context: self.backgroundContext)
+        let context = makeContext()
+        context.name = "storeContactImageEntry"
+        await context.perform {
+            let newContactImageEntry = ContactImageEntryStored(context: context)
 
             newContactImageEntry.id = UUID()
             newContactImageEntry.name = contactImageEntry.name
@@ -96,8 +101,8 @@ final class BaseContactImageStorage: ContactImageStorage, Injectable {
             newContactImageEntry.fontWeight = contactImageEntry.fontWeight.asString
 
             do {
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save Contact Trick Entry to Core Data with error: \(error.userInfo)"
@@ -114,12 +119,14 @@ final class BaseContactImageStorage: ContactImageStorage, Injectable {
     ///
     /// - Parameter contactImageEntry: The `ContactImageEntry` object with updated values.
     func updateContactImageEntry(_ contactImageEntry: ContactImageEntry) async {
-        await backgroundContext.perform {
+        let context = makeContext()
+        context.name = "updateContactImageEntry"
+        await context.perform {
             let fetchRequest: NSFetchRequest<ContactImageEntryStored> = ContactImageEntryStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "contactId == %@", contactImageEntry.contactId ?? "")
 
             do {
-                if let existingEntry = try self.backgroundContext.fetch(fetchRequest).first {
+                if let existingEntry = try context.fetch(fetchRequest).first {
                     // Update the properties of the existing entry
                     existingEntry.name = contactImageEntry.name
                     existingEntry.layout = contactImageEntry.layout.rawValue
@@ -136,8 +143,8 @@ final class BaseContactImageStorage: ContactImageStorage, Injectable {
                     existingEntry.fontWeight = contactImageEntry.fontWeight.asString
                     existingEntry.fontWidth = contactImageEntry.fontWidth.asString
 
-                    guard self.backgroundContext.hasChanges else { return }
-                    try self.backgroundContext.save()
+                    guard context.hasChanges else { return }
+                    try context.save()
                 } else {
                     debugPrint(
                         "\(DebuggingIdentifiers.failed) \(#file) \(#function) No matching Contact Trick Entry found to update."

+ 30 - 27
Trio/Sources/APS/Storage/DeterminationStorage.swift

@@ -18,14 +18,16 @@ protocol DeterminationStorage {
 
 final class BaseDeterminationStorage: DeterminationStorage, Injectable {
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-    private let context: NSManagedObjectContext
+    private let makeContext: () -> NSManagedObjectContext
 
-    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
-        self.context = context ?? CoreDataStack.shared.newTaskContext()
+    init(resolver: Resolver, contextProvider: (() -> NSManagedObjectContext)? = nil) {
+        makeContext = contextProvider ?? { CoreDataStack.shared.newTaskContext() }
         injectServices(resolver)
     }
 
     func fetchLastDeterminationObjectID(predicate: NSPredicate) async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "fetchLastDeterminationObjectID"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
             onContext: context,
@@ -116,31 +118,30 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable {
 
     // Convert NSSet to array of Ints for Predictions
     func parseForecastValues(ofType type: String, from determinationID: NSManagedObjectID) async -> [Int]? {
-        let forecastIDs = await getForecastIDs(for: determinationID, in: context)
-
-        var forecastValuesList: [Int] = []
-
-        for forecastID in forecastIDs {
-            await context.perform {
-                if let forecast = try? self.context.existingObject(with: forecastID) as? Forecast {
-                    // Filter the forecast based on the type
-                    if forecast.type == type {
-                        let forecastValueIDs = forecast.forecastValues?.sorted(by: { $0.index < $1.index }).map(\.objectID) ?? []
-
-                        for forecastValueID in forecastValueIDs {
-                            if let forecastValue = try? self.context
-                                .existingObject(with: forecastValueID) as? ForecastValue
-                            {
-                                let forecastValueInt = Int(forecastValue.value)
-                                forecastValuesList.append(forecastValueInt)
-                            }
-                        }
-                    }
-                }
+        let context = makeContext()
+        context.name = "parseForecastValues"
+
+        return await context.perform {
+            let request = NSFetchRequest<Forecast>(entityName: "Forecast")
+            request.predicate = NSPredicate(
+                format: "orefDetermination = %@ AND type == %@",
+                determinationID,
+                type
+            )
+            request.fetchLimit = 1
+            request.relationshipKeyPathsForPrefetching = ["forecastValues"]
+
+            do {
+                guard let forecast = try context.fetch(request).first else { return nil }
+                let values = forecast.forecastValuesArray.map { Int($0.value) }
+                return values.isEmpty ? nil : values
+            } catch {
+                debugPrint(
+                    "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch forecast of type \(type): \(error)"
+                )
+                return nil
             }
         }
-
-        return forecastValuesList.isEmpty ? nil : forecastValuesList
     }
 
     func getOrefDeterminationNotYetUploadedToNightscout(_ determinationIds: [NSManagedObjectID]) async -> Determination? {
@@ -157,9 +158,11 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable {
             uam: await parseForecastValues(ofType: "uam", from: determinationId)
         )
 
+        let context = makeContext()
+        context.name = "getOrefDeterminationNotYetUploadedToNightscout"
         return await context.perform {
             do {
-                let orefDetermination = try self.context.existingObject(with: determinationId) as? OrefDetermination
+                let orefDetermination = try context.existingObject(with: determinationId) as? OrefDetermination
 
                 // Check if the fetched object is of the expected type
                 if let orefDetermination = orefDetermination {

+ 74 - 37
Trio/Sources/APS/Storage/GlucoseStorage.swift

@@ -45,10 +45,10 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         static let minimumGlucose: Int = 39
     }
 
-    private let context: NSManagedObjectContext
+    private let makeContext: () -> NSManagedObjectContext
 
-    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
-        self.context = context ?? CoreDataStack.shared.newTaskContext()
+    init(resolver: Resolver, contextProvider: (() -> NSManagedObjectContext)? = nil) {
+        makeContext = contextProvider ?? { CoreDataStack.shared.newTaskContext() }
         injectServices(resolver)
     }
 
@@ -77,27 +77,32 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     ///  it isn't within 3.5 minutes of an existing glucose reading, which is simple but not perfect.
     ///  But since this is a corner case that really shouldn't happen often, it's good enough.
     func backfillGlucose(_ glucose: [BloodGlucose]) async throws {
+        let context = makeContext()
+        context.name = "backfillGlucose"
         let clamped = clampToMinimum(glucose)
+
         try await context.perform {
             // remove already deleted glucose values
             let withoutDeletedGlucose = self.filterGlucoseValues(
                 clamped,
                 fetchRequest: DeletedGlucoseStored.fetchRequest(),
-                timeBuffer: 1
+                timeBuffer: 1,
+                context: context
             )
 
             // check for a 3.5 minute difference between existing values
             let filteredGlucose = self.filterGlucoseValues(
                 withoutDeletedGlucose,
                 fetchRequest: GlucoseStored.fetchRequest(),
-                timeBuffer: 3.5 * 60
+                timeBuffer: 3.5 * 60,
+                context: context
             )
 
             guard !filteredGlucose.isEmpty else { return }
 
             do {
                 // Store glucose values in Core Data
-                try self.storeGlucoseInCoreData(filteredGlucose)
+                try self.storeGlucoseInCoreData(filteredGlucose, context: context)
             } catch {
                 throw CoreDataError.creationError(
                     function: #function,
@@ -108,15 +113,23 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
 
     func storeGlucose(_ glucose: [BloodGlucose]) async throws {
+        let context = makeContext()
+        context.name = "storeGlucose"
         let clamped = clampToMinimum(glucose)
+
         try await context.perform {
             // Get new glucose values that don't exist yet
-            let newGlucose = self.filterGlucoseValues(clamped, fetchRequest: GlucoseStored.fetchRequest(), timeBuffer: 1)
+            let newGlucose = self.filterGlucoseValues(
+                clamped,
+                fetchRequest: GlucoseStored.fetchRequest(),
+                timeBuffer: 1,
+                context: context
+            )
             guard !newGlucose.isEmpty else { return }
 
             do {
                 // Store glucose values in Core Data
-                try self.storeGlucoseInCoreData(newGlucose)
+                try self.storeGlucoseInCoreData(newGlucose, context: context)
             } catch {
                 throw CoreDataError.creationError(
                     function: #function,
@@ -161,7 +174,8 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     private func filterGlucoseValues(
         _ glucose: [BloodGlucose],
         fetchRequest: NSFetchRequest<NSFetchRequestResult>,
-        timeBuffer: TimeInterval
+        timeBuffer: TimeInterval,
+        context: NSManagedObjectContext
     ) -> [BloodGlucose] {
         let datesToCheck = glucose.map(\.dateString).sorted()
         guard let firstDate = datesToCheck.first.map({ $0.addingTimeInterval(-timeBuffer) }),
@@ -198,15 +212,15 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         }
     }
 
-    private func storeGlucoseInCoreData(_ glucose: [BloodGlucose]) throws {
+    private func storeGlucoseInCoreData(_ glucose: [BloodGlucose], context: NSManagedObjectContext) throws {
         if glucose.count > 1 {
-            try storeGlucoseBatch(glucose)
+            try storeGlucoseBatch(glucose, context: context)
         } else {
-            try storeGlucoseRegular(glucose)
+            try storeGlucoseRegular(glucose, context: context)
         }
     }
 
-    private func storeGlucoseRegular(_ glucose: [BloodGlucose]) throws {
+    private func storeGlucoseRegular(_ glucose: [BloodGlucose], context: NSManagedObjectContext) throws {
         for entry in glucose {
             let glucoseEntry = GlucoseStored(context: context)
             configureGlucoseEntry(glucoseEntry, with: entry)
@@ -217,7 +231,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         updateSubject.send()
     }
 
-    private func storeGlucoseBatch(_ glucose: [BloodGlucose]) throws {
+    private func storeGlucoseBatch(_ glucose: [BloodGlucose], context: NSManagedObjectContext) throws {
         var remainingGlucose = glucose
         let batchInsert = NSBatchInsertRequest(
             entity: GlucoseStored.entity(),
@@ -314,8 +328,11 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
 
     func addManualGlucose(glucose: Int) {
+        let context = makeContext()
+        context.name = "addManualGlucose"
+
         context.perform {
-            let newItem = GlucoseStored(context: self.context)
+            let newItem = GlucoseStored(context: context)
             newItem.id = UUID()
             newItem.date = Date()
             newItem.glucose = Int16(glucose)
@@ -325,8 +342,8 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
             newItem.isUploadedToTidepool = false
 
             do {
-                guard self.context.hasChanges else { return }
-                try self.context.save()
+                guard context.hasChanges else { return }
+                try context.save()
 
                 // Glucose subscribers already listen to the update publisher, so call here to update glucose-related data.
                 self.updateSubject.send()
@@ -344,8 +361,10 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
 
     func syncDate() -> Date {
+        let context = makeContext()
+        context.name = "syncDate"
+
         // Optimize fetch request to only get the date
-        let taskContext = CoreDataStack.shared.newTaskContext()
         let fr = NSFetchRequest<NSDictionary>(entityName: "GlucoseStored")
         fr.predicate = NSPredicate.predicateForOneDayAgo
         fr.propertiesToFetch = ["date"]
@@ -355,9 +374,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
         var fetchedDate: Date = .distantPast
 
-        taskContext.performAndWait {
+        context.performAndWait {
             do {
-                if let result = try taskContext.fetch(fr).first,
+                if let result = try context.fetch(fr).first,
                    let date = result["date"] as? Date
                 {
                     fetchedDate = date
@@ -371,6 +390,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
 
     func lastGlucoseDate() -> Date? {
+        let context = makeContext()
+        context.name = "lastGlucoseDate"
+
         let fetchRequest = GlucoseStored.fetchRequest()
         fetchRequest.predicate = NSPredicate.predicateForOneDayAgo
         fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \GlucoseStored.date, ascending: false)]
@@ -379,7 +401,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         var date: Date?
         context.performAndWait {
             do {
-                let results = try self.context.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 date = results.first?.date
             } catch let error as NSError {
                 debug(.storage, "Fetch error: \(DebuggingIdentifiers.failed) \(error), \(error.userInfo)")
@@ -409,7 +431,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         return filtered
     }
 
-    func fetchLatestGlucose() throws -> GlucoseStored? {
+    func fetchLatestGlucose(context: NSManagedObjectContext) throws -> GlucoseStored? {
         let predicate = NSPredicate.predicateFor20MinAgo
         return (try CoreDataStack.shared.fetchEntities(
             ofType: GlucoseStored.self,
@@ -424,6 +446,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     // Fetch glucose that is not uploaded to Nightscout yet
     /// - Returns: Array of BloodGlucose to ensure the correct format for the NS Upload
     func getGlucoseNotYetUploadedToNightscout() async throws -> [BloodGlucose] {
+        let context = makeContext()
+        context.name = "getGlucoseNotYetUploadedToNightscout"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: context,
@@ -477,6 +502,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     // Fetch glucose that is not uploaded to Nightscout yet
     /// - Returns: Array of BloodGlucose to ensure the correct format for the NS Upload
     func getGlucoseNotYetUploadedToHealth() async throws -> [BloodGlucose] {
+        let context = makeContext()
+        context.name = "getGlucoseNotYetUploadedToHealth"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: context,
@@ -509,6 +537,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     // Fetch manual glucose that is not uploaded to Nightscout yet
     /// - Returns: Array of NightscoutTreatment to ensure the correct format for the NS Upload
     func getManualGlucoseNotYetUploadedToHealth() async throws -> [BloodGlucose] {
+        let context = makeContext()
+        context.name = "getManualGlucoseNotYetUploadedToHealth"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: context,
@@ -541,6 +572,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     // Fetch glucose that is not uploaded to Tidepool yet
     /// - Returns: Array of StoredGlucoseSample to ensure the correct format for Tidepool upload
     func getGlucoseNotYetUploadedToTidepool() async throws -> [StoredGlucoseSample] {
+        let context = makeContext()
+        context.name = "getGlucoseNotYetUploadedToTidepool"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: context,
@@ -574,6 +608,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     // Fetch manual glucose that is not uploaded to Tidepool yet
     /// - Returns: Array of StoredGlucoseSample to ensure the correct format for the Tidepool upload
     func getManualGlucoseNotYetUploadedToTidepool() async throws -> [StoredGlucoseSample] {
+        let context = makeContext()
+        context.name = "getManualGlucoseNotYetUploadedToTidepool"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: context,
@@ -726,16 +763,13 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 //    }
 
     func deleteGlucose(_ treatmentObjectID: NSManagedObjectID) async {
-        // Use injected context if available, otherwise create new task context
-        let taskContext = context != CoreDataStack.shared.newTaskContext()
-            ? context
-            : CoreDataStack.shared.newTaskContext()
-        taskContext.name = "deleteContext"
-        taskContext.transactionAuthor = "deleteGlucose"
-
-        await taskContext.perform {
+        let context = makeContext()
+        context.name = "deleteGlucose"
+        context.transactionAuthor = "deleteGlucose"
+
+        await context.perform {
             do {
-                let result = try taskContext.existingObject(with: treatmentObjectID) as? GlucoseStored
+                let result = try context.existingObject(with: treatmentObjectID) as? GlucoseStored
 
                 guard let glucoseToDelete = result else {
                     debugPrint("Data Table State: \(#function) \(DebuggingIdentifiers.failed) glucose not found in core data")
@@ -744,16 +778,16 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
                 // Create a new DeletedGlucoseStored object and copy the properties
                 if let date = glucoseToDelete.date {
-                    let deletedEntry = DeletedGlucoseStored(context: taskContext)
+                    let deletedEntry = DeletedGlucoseStored(context: context)
                     deletedEntry.date = date
                     deletedEntry.glucose = glucoseToDelete.glucose
                     deletedEntry.isManualGlucoseEntry = glucoseToDelete.isManual
                 }
 
-                taskContext.delete(glucoseToDelete)
+                context.delete(glucoseToDelete)
 
-                guard taskContext.hasChanges else { return }
-                try taskContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
                 debugPrint("\(#file) \(#function) \(DebuggingIdentifiers.succeeded) deleted glucose from core data")
             } catch {
                 debugPrint(
@@ -764,10 +798,13 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
 
     var alarm: GlucoseAlarm? {
+        let context = makeContext()
+        context.name = "alarm"
+
         /// glucose can not be older than 20 minutes due to the predicate in the fetch request
-        context.performAndWait {
+        return context.performAndWait {
             do {
-                guard let glucose = try fetchLatestGlucose() else { return nil }
+                guard let glucose = try fetchLatestGlucose(context: context) else { return nil }
 
                 let glucoseValue = glucose.glucose
 

+ 35 - 12
Trio/Sources/APS/Storage/OverrideStorage.swift

@@ -25,10 +25,10 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     @Injected() private var settingsManager: SettingsManager!
 
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-    private let context: NSManagedObjectContext
+    private let makeContext: () -> NSManagedObjectContext
 
-    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
-        self.context = context ?? CoreDataStack.shared.newTaskContext()
+    init(resolver: Resolver, contextProvider: (() -> NSManagedObjectContext)? = nil) {
+        makeContext = contextProvider ?? { CoreDataStack.shared.newTaskContext() }
         injectServices(resolver)
     }
 
@@ -41,6 +41,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     }
 
     func fetchLastCreatedOverride() async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "fetchLastCreatedOverride"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             onContext: context,
@@ -63,6 +66,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     }
 
     func loadLatestOverrideConfigurations(fetchLimit: Int) async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "loadLatestOverrideConfigurations"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             onContext: context,
@@ -83,6 +89,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
 
     /// Returns the NSManagedObjectID of the Override Presets
     func fetchForOverridePresets() async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "fetchForOverridePresets"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             onContext: context,
@@ -114,8 +123,11 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
             presetCount = presets.count
         }
 
+        let context = makeContext()
+        context.name = "storeOverride"
+
         try await context.perform {
-            let newOverride = OverrideStored(context: self.context)
+            let newOverride = OverrideStored(context: context)
 
             // override key meta data
             if !override.name.isEmpty {
@@ -163,8 +175,8 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
                 newOverride.smbIsScheduledOff = false
             }
 
-            guard self.context.hasChanges else { return }
-            try self.context.save()
+            guard context.hasChanges else { return }
+            try context.save()
         }
     }
 
@@ -213,12 +225,8 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
 
     /// - Parameter: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
     func deleteOverridePreset(_ objectID: NSManagedObjectID) async {
-        // Use injected context if available, otherwise create new task context
-        let taskContext = context != CoreDataStack.shared.newTaskContext()
-            ? context
-            : CoreDataStack.shared.newTaskContext()
-
-        taskContext.name = "deleteContext"
+        let taskContext = makeContext()
+        taskContext.name = "deleteOverridePreset"
         taskContext.transactionAuthor = "deleteOverride"
 
         await taskContext.perform {
@@ -243,6 +251,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     }
 
     func getOverridesNotYetUploadedToNightscout() async throws -> [NightscoutExercise] {
+        let context = makeContext()
+        context.name = "getOverridesNotYetUploadedToNightscout"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             onContext: context,
@@ -271,6 +282,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     }
 
     func getOverrideRunsNotYetUploadedToNightscout() async throws -> [NightscoutExercise] {
+        let context = makeContext()
+        context.name = "getOverrideRunsNotYetUploadedToNightscout"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideRunStored.self,
             onContext: context,
@@ -328,6 +342,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
 
         /// Build a predicate to fetch a stored override (from OverrideStored) whose date is within the tolerance window.
         let predicate = NSPredicate(format: "date >= %@ AND date <= %@", lowerBound as NSDate, upperBound as NSDate)
+        let context = makeContext()
+        context.name = "checkIfShouldDeleteNightscoutOverrideEntry"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             onContext: context,
@@ -360,6 +377,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     }
 
     func getPresetOverridesForNightscout() async throws -> [NightscoutPresetOverride] {
+        let context = makeContext()
+        context.name = "getPresetOverridesForNightscout"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             onContext: context,
@@ -389,6 +409,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     }
 
     func fetchLatestActiveOverride() async throws -> NSManagedObjectID? {
+        let context = makeContext()
+        context.name = "fetchLatestActiveOverride"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             onContext: context,

+ 103 - 131
Trio/Sources/APS/Storage/PumpHistoryStorage.swift

@@ -31,10 +31,10 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
         updateSubject.eraseToAnyPublisher()
     }
 
-    private let context: NSManagedObjectContext
+    private let makeContext: () -> NSManagedObjectContext
 
-    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
-        self.context = context ?? CoreDataStack.shared.newTaskContext()
+    init(resolver: Resolver, contextProvider: (() -> NSManagedObjectContext)? = nil) {
+        makeContext = contextProvider ?? { CoreDataStack.shared.newTaskContext() }
         injectServices(resolver)
     }
 
@@ -47,178 +47,136 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     }
 
     func storePumpEvents(_ events: [NewPumpEvent]) async throws {
+        let context = makeContext()
+        context.name = "storePumpEvents"
         try await context.perform {
-            for event in events {
-                let existingEvents: [PumpEventStored] = try CoreDataStack.shared.fetchEntities(
-                    ofType: PumpEventStored.self,
-                    onContext: self.context,
-                    predicate: NSPredicate.duplicates(event.date),
-                    key: "timestamp",
-                    ascending: false,
-                    batchSize: 50
-                ) as? [PumpEventStored] ?? []
+            // Batched de-duplication: fetch every already-stored event whose timestamp matches one of the
+            // incoming events in a single request, instead of one fetch per event (avoids an N+1 query).
+            // The (timestamp, type) key mirrors the uniqueness constraint on PumpEventStored, which acts as
+            // a race-safe backstop should a concurrent context insert the same event.
+            let incomingTimestamps = events.map { $0.date as NSDate }
+            let existingEvents: [PumpEventStored] = try CoreDataStack.shared.fetchEntities(
+                ofType: PumpEventStored.self,
+                onContext: context,
+                predicate: NSPredicate(format: "timestamp IN %@", incomingTimestamps),
+                key: "timestamp",
+                ascending: false,
+                batchSize: 50
+            ) as? [PumpEventStored] ?? []
+
+            // (timestamp, type) -> stored event, for O(1) look-up inside the loop. Newly created events are
+            // inserted here too, so duplicates within the same batch are caught as well.
+            func dedupKey(timestamp: Date?, type: String?) -> String {
+                "\(timestamp?.timeIntervalSinceReferenceDate ?? 0)|\(type ?? "")"
+            }
+            var existingByKey: [String: PumpEventStored] = [:]
+            for stored in existingEvents {
+                existingByKey[dedupKey(timestamp: stored.timestamp, type: stored.type)] = stored
+            }
+
+            // Creates a bare PumpEventStored unless a (timestamp, type) duplicate already exists.
+            // Returns the new event, or nil if it was a duplicate.
+            @discardableResult func makeEventIfNew(timestamp: Date, type: PumpEvent) -> PumpEventStored? {
+                let key = dedupKey(timestamp: timestamp, type: type.rawValue)
+                guard existingByKey[key] == nil else {
+                    debug(.coreData, "Duplicate event found with timestamp: \(timestamp)")
+                    return nil
+                }
+                let newPumpEvent = PumpEventStored(context: context)
+                newPumpEvent.id = UUID().uuidString
+                newPumpEvent.timestamp = timestamp
+                newPumpEvent.type = type.rawValue
+                newPumpEvent.isUploadedToNS = false
+                newPumpEvent.isUploadedToHealth = false
+                newPumpEvent.isUploadedToTidepool = false
+                existingByKey[key] = newPumpEvent
+                return newPumpEvent
+            }
 
+            for event in events {
                 switch event.type {
                 case .bolus:
-
                     guard let dose = event.dose else { continue }
                     let amount = self.roundDose(
                         dose.unitsInDeliverableIncrements,
                         toIncrement: Double(self.settings.preferences.bolusIncrement)
                     )
+                    // Use the event's own timestamp to dedup
+                    let timestamp = event.date
+                    let key = dedupKey(timestamp: timestamp, type: PumpEvent.bolus.rawValue)
 
-                    guard existingEvents.isEmpty else {
+                    if let existingEvent = existingByKey[key] {
                         // Duplicate found, do not store the event
                         debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
 
-                        if let existingEvent = existingEvents.first(where: { $0.type == EventType.bolus.rawValue }) {
-                            if existingEvent.timestamp == event.date {
-                                if let existingAmount = existingEvent.bolus?.amount, amount < existingAmount as Decimal {
-                                    // Update existing event with new smaller value
-                                    existingEvent.bolus?.amount = amount as NSDecimalNumber
-                                    existingEvent.bolus?.isSMB = dose.automatic ?? true
-                                    existingEvent.isUploadedToNS = false
-                                    existingEvent.isUploadedToHealth = false
-                                    existingEvent.isUploadedToTidepool = false
-
-                                    debug(.coreData, "Updated existing event with smaller value: \(amount)")
-                                }
-                            }
+                        if let existingAmount = existingEvent.bolus?.amount, amount < existingAmount as Decimal {
+                            // Update existing event with new smaller value (e.g. a cancelled / partial bolus)
+                            existingEvent.bolus?.amount = amount as NSDecimalNumber
+                            existingEvent.bolus?.isSMB = dose.automatic ?? true
+                            existingEvent.isUploadedToNS = false
+                            existingEvent.isUploadedToHealth = false
+                            existingEvent.isUploadedToTidepool = false
+
+                            debug(.coreData, "Updated existing event with smaller value: \(amount)")
                         }
                         continue
                     }
 
-                    let newPumpEvent = PumpEventStored(context: self.context)
+                    let newPumpEvent = PumpEventStored(context: context)
                     newPumpEvent.id = UUID().uuidString
-                    // restrict entry to now or past
-                    newPumpEvent.timestamp = event.date > Date() ? Date() : event.date
+                    newPumpEvent.timestamp = timestamp
                     newPumpEvent.type = PumpEvent.bolus.rawValue
                     newPumpEvent.isUploadedToNS = false
                     newPumpEvent.isUploadedToHealth = false
                     newPumpEvent.isUploadedToTidepool = false
 
-                    let newBolusEntry = BolusStored(context: self.context)
+                    let newBolusEntry = BolusStored(context: context)
                     newBolusEntry.pumpEvent = newPumpEvent
                     newBolusEntry.amount = NSDecimalNumber(decimal: amount)
                     newBolusEntry.isExternal = dose.manuallyEntered
                     newBolusEntry.isSMB = dose.automatic ?? true
 
+                    existingByKey[key] = newPumpEvent
+
                 case .tempBasal:
                     guard let dose = event.dose else { continue }
 
-                    guard existingEvents.isEmpty else {
-                        // Duplicate found, do not store the event
-                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
+                    let delivered = dose.deliveredUnits
+                    let isCancel = delivered != nil
+                    guard !isCancel else { continue }
+
+                    guard let newPumpEvent = makeEventIfNew(timestamp: event.date, type: .tempBasal) else {
                         continue
                     }
 
                     let rate = Decimal(dose.unitsPerHour)
                     let minutes = (dose.endDate - dose.startDate).timeInterval / 60
-                    let delivered = dose.deliveredUnits
-                    let date = event.date
 
-                    let isCancel = delivered != nil
-                    guard !isCancel else { continue }
-
-                    let newPumpEvent = PumpEventStored(context: self.context)
-                    newPumpEvent.id = UUID().uuidString
-                    newPumpEvent.timestamp = date
-                    newPumpEvent.type = PumpEvent.tempBasal.rawValue
-                    newPumpEvent.isUploadedToNS = false
-                    newPumpEvent.isUploadedToHealth = false
-                    newPumpEvent.isUploadedToTidepool = false
-
-                    let newTempBasal = TempBasalStored(context: self.context)
+                    let newTempBasal = TempBasalStored(context: context)
                     newTempBasal.pumpEvent = newPumpEvent
                     newTempBasal.duration = Int16(round(minutes))
                     newTempBasal.rate = rate as NSDecimalNumber
                     newTempBasal.tempType = TempType.absolute.rawValue
 
                 case .suspend:
-                    guard existingEvents.isEmpty else {
-                        // Duplicate found, do not store the event
-                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
-                        continue
-                    }
-                    let newPumpEvent = PumpEventStored(context: self.context)
-                    newPumpEvent.id = UUID().uuidString
-                    newPumpEvent.timestamp = event.date
-                    newPumpEvent.type = PumpEvent.pumpSuspend.rawValue
-                    newPumpEvent.isUploadedToNS = false
-                    newPumpEvent.isUploadedToHealth = false
-                    newPumpEvent.isUploadedToTidepool = false
+                    makeEventIfNew(timestamp: event.date, type: .pumpSuspend)
 
                 case .resume:
-                    guard existingEvents.isEmpty else {
-                        // Duplicate found, do not store the event
-                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
-                        continue
-                    }
-                    let newPumpEvent = PumpEventStored(context: self.context)
-                    newPumpEvent.id = UUID().uuidString
-                    newPumpEvent.timestamp = event.date
-                    newPumpEvent.type = PumpEvent.pumpResume.rawValue
-                    newPumpEvent.isUploadedToNS = false
-                    newPumpEvent.isUploadedToHealth = false
-                    newPumpEvent.isUploadedToTidepool = false
+                    makeEventIfNew(timestamp: event.date, type: .pumpResume)
 
                 case .rewind:
-                    guard existingEvents.isEmpty else {
-                        // Duplicate found, do not store the event
-                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
-                        continue
-                    }
-                    let newPumpEvent = PumpEventStored(context: self.context)
-                    newPumpEvent.id = UUID().uuidString
-                    newPumpEvent.timestamp = event.date
-                    newPumpEvent.type = PumpEvent.rewind.rawValue
-                    newPumpEvent.isUploadedToNS = false
-                    newPumpEvent.isUploadedToHealth = false
-                    newPumpEvent.isUploadedToTidepool = false
+                    makeEventIfNew(timestamp: event.date, type: .rewind)
 
                 case .prime:
-                    guard existingEvents.isEmpty else {
-                        // Duplicate found, do not store the event
-                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
-                        continue
-                    }
-                    let newPumpEvent = PumpEventStored(context: self.context)
-                    newPumpEvent.id = UUID().uuidString
-                    newPumpEvent.timestamp = event.date
-                    newPumpEvent.type = PumpEvent.prime.rawValue
-                    newPumpEvent.isUploadedToNS = false
-                    newPumpEvent.isUploadedToHealth = false
-                    newPumpEvent.isUploadedToTidepool = false
+                    makeEventIfNew(timestamp: event.date, type: .prime)
 
                 case .alarm:
-                    guard existingEvents.isEmpty else {
-                        // Duplicate found, do not store the event
-                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
-                        continue
-                    }
-                    let newPumpEvent = PumpEventStored(context: self.context)
-                    newPumpEvent.id = UUID().uuidString
-                    newPumpEvent.timestamp = event.date
-                    newPumpEvent.type = PumpEvent.pumpAlarm.rawValue
-                    newPumpEvent.isUploadedToNS = false
-                    newPumpEvent.isUploadedToHealth = false
-                    newPumpEvent.isUploadedToTidepool = false
-                    newPumpEvent.note = event.title
+                    let newPumpEvent = makeEventIfNew(timestamp: event.date, type: .pumpAlarm)
+                    newPumpEvent?.note = event.title
 
                 case .replaceComponent(componentType: .infusionSet),
                      .replaceComponent(componentType: .pump):
-                    guard existingEvents.isEmpty else {
-                        // Duplicate found, do not store the event
-                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
-                        continue
-                    }
-                    let newPumpEvent = PumpEventStored(context: self.context)
-                    newPumpEvent.id = UUID().uuidString
-                    newPumpEvent.timestamp = event.date
-                    newPumpEvent.type = PumpEvent.siteChange.rawValue
-                    newPumpEvent.isUploadedToNS = false
-                    newPumpEvent.isUploadedToHealth = false
-                    newPumpEvent.isUploadedToTidepool = false
+                    makeEventIfNew(timestamp: event.date, type: .siteChange)
 
                 default:
                     continue
@@ -226,8 +184,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
             }
 
             do {
-                guard self.context.hasChanges else { return }
-                try self.context.save()
+                guard context.hasChanges else { return }
+                try context.save()
 
                 self.updateSubject.send(())
                 debug(.coreData, "\(DebuggingIdentifiers.succeeded) stored pump events in Core Data")
@@ -239,9 +197,11 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     }
 
     func storeExternalInsulinEvent(amount: Decimal, timestamp: Date) async {
+        let context = makeContext()
+        context.name = "storeExternalInsulinEvent"
         await context.perform {
             // create pump event
-            let newPumpEvent = PumpEventStored(context: self.context)
+            let newPumpEvent = PumpEventStored(context: context)
             newPumpEvent.id = UUID().uuidString
             // restrict entry to now or past
             newPumpEvent.timestamp = timestamp > Date() ? Date() : timestamp
@@ -251,15 +211,15 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
             newPumpEvent.isUploadedToTidepool = false
 
             // create bolus entry and specify relationship to pump event
-            let newBolusEntry = BolusStored(context: self.context)
+            let newBolusEntry = BolusStored(context: context)
             newBolusEntry.pumpEvent = newPumpEvent
             newBolusEntry.amount = amount as NSDecimalNumber
             newBolusEntry.isExternal = true // we are creating an external dose
             newBolusEntry.isSMB = false // the dose is manually administered
 
             do {
-                guard self.context.hasChanges else { return }
-                try self.context.save()
+                guard context.hasChanges else { return }
+                try context.save()
                 debug(.coreData, "External insulin saved")
                 self.updateSubject.send(())
             } catch {
@@ -269,12 +229,15 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     }
 
     func getPumpHistory() async throws -> [PumpHistoryEvent] {
+        let context = makeContext()
+        context.name = "getPumpHistory"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             onContext: context,
             predicate: NSPredicate.pumpHistoryLast24h,
             key: "timestamp",
-            ascending: false
+            ascending: false,
+            relationshipKeyPathsForPrefetching: ["bolus", "tempBasal"]
         )
 
         return await context.perform {
@@ -318,12 +281,15 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     }
 
     func getPumpHistoryNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let context = makeContext()
+        context.name = "getPumpHistoryNotYetUploadedToNightscout"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             onContext: context,
             predicate: NSPredicate.pumpEventsNotYetUploadedToNightscout,
             key: "timestamp",
-            ascending: false
+            ascending: false,
+            relationshipKeyPathsForPrefetching: ["bolus", "tempBasal"]
         )
 
         return try await context.perform { [self] in
@@ -479,12 +445,15 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     }
 
     func getPumpHistoryNotYetUploadedToHealth() async throws -> [PumpHistoryEvent] {
+        let context = makeContext()
+        context.name = "getPumpHistoryNotYetUploadedToHealth"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             onContext: context,
             predicate: NSPredicate.pumpEventsNotYetUploadedToHealth,
             key: "timestamp",
-            ascending: false
+            ascending: false,
+            relationshipKeyPathsForPrefetching: ["bolus", "tempBasal"]
         )
 
         return try await context.perform {
@@ -523,12 +492,15 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     }
 
     func getPumpHistoryNotYetUploadedToTidepool() async throws -> [PumpHistoryEvent] {
+        let context = makeContext()
+        context.name = "getPumpHistoryNotYetUploadedToTidepool"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             onContext: context,
             predicate: NSPredicate.pumpEventsNotYetUploadedToTidepool,
             key: "timestamp",
-            ascending: false
+            ascending: false,
+            relationshipKeyPathsForPrefetching: ["bolus", "tempBasal"]
         )
 
         return try await context.perform {

+ 54 - 28
Trio/Sources/APS/Storage/TDDStorage.swift

@@ -28,9 +28,10 @@ struct TDDResult {
 final class BaseTDDStorage: TDDStorage, Injectable {
     @Injected() private var storage: FileStorage!
 
-    private let privateContext = CoreDataStack.shared.newTaskContext()
+    private let makeContext: () -> NSManagedObjectContext
 
-    init(resolver: Resolver) {
+    init(resolver: Resolver, contextProvider: (() -> NSManagedObjectContext)? = nil) {
+        makeContext = contextProvider ?? { CoreDataStack.shared.newTaskContext() }
         injectServices(resolver)
     }
 
@@ -144,8 +145,10 @@ final class BaseTDDStorage: TDDStorage, Injectable {
     /// Stores the Total Daily Dose (TDD) result in Core Data
     /// - Parameter tddResult: The TDD result to store, containing total insulin, bolus, temp basal, scheduled basal and weighted average
     func storeTDD(_ tddResult: TDDResult) async {
-        await privateContext.perform {
-            let tddStored = TDDStored(context: self.privateContext)
+        let context = makeContext()
+        context.name = "storeTDD"
+        await context.perform {
+            let tddStored = TDDStored(context: context)
             tddStored.id = UUID()
             tddStored.date = Date()
             tddStored.total = NSDecimalNumber(decimal: tddResult.total)
@@ -155,8 +158,8 @@ final class BaseTDDStorage: TDDStorage, Injectable {
             tddStored.weightedAverage = tddResult.weightedAverage.map { NSDecimalNumber(decimal: $0) }
 
             do {
-                guard self.privateContext.hasChanges else { return }
-                try self.privateContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch {
                 debug(.apsManager, "\(DebuggingIdentifiers.failed) Failed to save TDD: \(error)")
             }
@@ -564,38 +567,29 @@ final class BaseTDDStorage: TDDStorage, Injectable {
     /// - Returns: A weighted average of TDD as Decimal, or nil if insufficient data
     /// - Note: The weight percentage can be configured in preferences. Default is 0.65 (65% recent, 35% historical)
     private func calculateWeightedAverage() async throws -> Decimal? {
-        // Fetch data from Core Data
         let tenDaysAgo = Date().addingTimeInterval(-10.days.timeInterval)
         let twoHoursAgo = Date().addingTimeInterval(-2.hours.timeInterval)
 
-        let predicate = NSPredicate(format: "date >= %@", tenDaysAgo as NSDate)
+        let context = makeContext()
+        context.name = "calculateWeightedAverage"
 
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: TDDStored.self,
-            onContext: privateContext,
-            predicate: predicate,
-            key: "date",
-            ascending: false
-        )
-        return await privateContext.perform { () -> Decimal? in
-            guard let results = results as? [TDDStored], !results.isEmpty else { return 0 }
+        return try await context.perform { () -> Decimal? in
+            let recent = try Self.aggregateTDD(from: twoHoursAgo, in: context)
+            let historical = try Self.aggregateTDD(from: tenDaysAgo, in: context)
 
-            // Calculate recent (2h) average
-            let recentResults = results.filter { $0.date?.timeIntervalSince(twoHoursAgo) ?? 0 > 0 }
-            let recentTotal = recentResults.compactMap { $0.total?.decimalValue }.reduce(0, +)
-            let recentCount = max(Decimal(recentResults.count), 1)
-            let averageTDDLastTwoHours = recentTotal / recentCount
+            // Extract into locals so SwiftFormat's isEmpty rule doesn't
+            // mis-rewrite the tuple member access into `!tuple.isEmpty`
+            let historicalCount = historical.count
+            let recentCount = recent.count
+            guard historicalCount > 0 else { return 0 }
 
-            // Calculate 10-day average
-            let totalTDD = results.compactMap { $0.total?.decimalValue }.reduce(0, +)
-            let totalCount = max(Decimal(results.count), 1)
-            let averageTDDLastTenDays = totalTDD / totalCount
+            let averageTDDLastTwoHours = recent.total / max(Decimal(recentCount), 1)
+            let averageTDDLastTenDays = historical.total / Decimal(historicalCount)
 
             // Get weight percentage from preferences (default 0.65 if not set)
             let userPreferences = self.storage.retrieve(OpenAPS.Settings.preferences, as: Preferences.self)
             let weightPercentage = userPreferences?.weightPercentage ?? Decimal(0.65) // why is this 1 as default in trio-oref??
 
-            // Calculate weighted average using the formula:
             // weightedTDD = (weightPercentage × recent_average) + ((1 - weightPercentage) × historical_average)
             let weightedTDD = weightPercentage * averageTDDLastTwoHours +
                 (1 - weightPercentage) * averageTDDLastTenDays
@@ -604,6 +598,36 @@ final class BaseTDDStorage: TDDStorage, Injectable {
         }
     }
 
+    /// Runs a SUM(total) + COUNT aggregate on TDDStored for records with date >= `from`,
+    /// avoiding materializing hundreds of rows just to add them up.
+    private static func aggregateTDD(
+        from: Date,
+        in context: NSManagedObjectContext
+    ) throws -> (total: Decimal, count: Int) {
+        let request = NSFetchRequest<NSDictionary>(entityName: "TDDStored")
+        request.resultType = .dictionaryResultType
+        request.predicate = NSPredicate(format: "date >= %@ AND total != nil", from as NSDate)
+
+        let sumExp = NSExpressionDescription()
+        sumExp.name = "sumTotal"
+        sumExp.expression = NSExpression(forFunction: "sum:", arguments: [NSExpression(forKeyPath: "total")])
+        sumExp.expressionResultType = .decimalAttributeType
+
+        let countExp = NSExpressionDescription()
+        countExp.name = "countTotal"
+        countExp.expression = NSExpression(forFunction: "count:", arguments: [NSExpression(forKeyPath: "total")])
+        countExp.expressionResultType = .integer64AttributeType
+
+        request.propertiesToFetch = [sumExp, countExp]
+
+        guard let row = try context.fetch(request).first else {
+            return (0, 0)
+        }
+        let sum = (row["sumTotal"] as? NSDecimalNumber)?.decimalValue ?? 0
+        let count = (row["countTotal"] as? NSNumber)?.intValue ?? 0
+        return (sum, count)
+    }
+
     /// Checks if there is enough Total Daily Dose (TDD) data collected over the past 7 days.
     ///
     /// This function performs a count fetch for TDDStored records in Core Data where:
@@ -616,7 +640,9 @@ final class BaseTDDStorage: TDDStorage, Injectable {
     /// - Returns: `true` if sufficient TDD data is available, otherwise `false`.
     /// - Throws: An error if the Core Data count operation fails.
     func hasSufficientTDD() async throws -> Bool {
-        try await BaseTDDStorage.hasSufficientTDD(context: privateContext)
+        let context = makeContext()
+        context.name = "hasSufficientTDD"
+        return try await BaseTDDStorage.hasSufficientTDD(context: context)
     }
 
     /// internal function with context exposed to enable testing

+ 39 - 16
Trio/Sources/APS/Storage/TempTargetsStorage.swift

@@ -33,14 +33,17 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
 
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
-    private let context: NSManagedObjectContext
+    private let makeContext: () -> NSManagedObjectContext
 
-    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
-        self.context = context ?? CoreDataStack.shared.newTaskContext()
+    init(resolver: Resolver, contextProvider: (() -> NSManagedObjectContext)? = nil) {
+        makeContext = contextProvider ?? { CoreDataStack.shared.newTaskContext() }
         injectServices(resolver)
     }
 
     func loadLatestTempTargetConfigurations(fetchLimit: Int) async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "loadLatestTempTargetConfigurations"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
             onContext: context,
@@ -61,6 +64,9 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
 
     /// Returns the NSManagedObjectID of the Temp Target Presets
     func fetchForTempTargetPresets() async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "fetchForTempTargetPresets"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
             onContext: context,
@@ -79,6 +85,9 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
 
     func fetchScheduledTempTargets() async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "fetchScheduledTempTargets"
+
         let scheduledTempTargets = NSPredicate(format: "date > %@", Date() as NSDate)
 
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
@@ -99,6 +108,9 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
 
     func fetchScheduledTempTarget(for targetDate: Date) async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "fetchScheduledTempTarget"
+
         let predicate = NSPredicate(format: "date == %@", targetDate as NSDate)
 
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
@@ -120,6 +132,9 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
 
     func storeTempTarget(tempTarget: TempTarget) async throws {
+        let context = makeContext()
+        context.name = "storeTempTarget"
+
         var presetCount = -1
         if tempTarget.isPreset == true {
             let presets = try await fetchForTempTargetPresets()
@@ -127,7 +142,7 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
         }
 
         try await context.perform {
-            let newTempTarget = TempTargetStored(context: self.context)
+            let newTempTarget = TempTargetStored(context: context)
             newTempTarget.date = tempTarget.createdAt
             newTempTarget.id = UUID()
             newTempTarget.enabled = tempTarget.enabled ?? false
@@ -152,8 +167,8 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
             }
 
             do {
-                guard self.context.hasChanges else { return }
-                try self.context.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debug(.default, "\(DebuggingIdentifiers.failed) Failed to save new temp target with error: \(error.userInfo)")
                 throw error
@@ -183,13 +198,16 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
 
     func existsTempTarget(with date: Date) async -> Bool {
-        await context.perform {
+        let context = makeContext()
+        context.name = "existsTempTarget"
+
+        return await context.perform {
             // Fetch all Temp Targets with the given date
             let fetchRequest: NSFetchRequest<TempTargetStored> = TempTargetStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "date == %@", date as NSDate)
 
             do {
-                let results = try self.context.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 return !results.isEmpty
             } catch let error as NSError {
                 debugPrint("\(DebuggingIdentifiers.failed) Failed to check for existing Temp Target: \(error)")
@@ -227,22 +245,21 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
 
     func deleteTempTargetPreset(_ objectID: NSManagedObjectID) async {
-        let taskContext = context != CoreDataStack.shared.newTaskContext()
-            ? context
-            : CoreDataStack.shared.newTaskContext()
+        let context = makeContext()
+        context.name = "deleteTempTargetPreset"
 
-        await taskContext.perform {
+        await context.perform {
             do {
-                let result = try taskContext.existingObject(with: objectID) as? TempTargetStored
+                let result = try context.existingObject(with: objectID) as? TempTargetStored
                 guard let tempTarget = result else {
                     debug(.default, "\(DebuggingIdentifiers.failed) Temp Target for batch delete not found.")
                     return
                 }
 
-                taskContext.delete(tempTarget)
+                context.delete(tempTarget)
 
-                guard taskContext.hasChanges else { return }
-                try taskContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch {
                 debug(.default, "\(DebuggingIdentifiers.failed) Failed to delete Temp Target: \(error)")
             }
@@ -272,6 +289,9 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
 
     func getTempTargetsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let context = makeContext()
+        context.name = "getTempTargetsNotYetUploadedToNightscout"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
             onContext: context,
@@ -309,6 +329,9 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
 
     func getTempTargetRunsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let context = makeContext()
+        context.name = "getTempTargetRunsNotYetUploadedToNightscout"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetRunStored.self,
             onContext: context,

+ 1 - 0
Trio/Sources/Helpers/CGMOptions.swift

@@ -4,6 +4,7 @@ let cgmOptions: [CGMOption] = [
     CGMOption(name: "Dexcom G7 / ONE+", predicate: { $0.type == .plugin && $0.displayName.contains("G7") }),
     CGMOption(name: "Dexcom Share", predicate: { $0.type == .plugin && $0.displayName.contains("Dexcom Share") }),
     CGMOption(name: "FreeStyle Libre", predicate: { $0.type == .plugin && $0.displayName == "FreeStyle Libre" }),
+    CGMOption(name: "Eversense", predicate: { $0.type == .plugin && $0.displayName == "Eversense" }),
     CGMOption(
         name: "FreeStyle Libre Demo",
         predicate: { $0.type == .plugin && $0.displayName == "FreeStyle Libre Demo" }

Разница между файлами не показана из-за своего большого размера
+ 27011 - 692
Trio/Sources/Localizations/Main/Localizable.xcstrings


+ 7 - 0
Trio/Sources/Models/BolusStatus.swift

@@ -0,0 +1,7 @@
+import LoopKit
+
+enum BolusStatus {
+    case noBolus
+    case initiating
+    case inProgress
+}

+ 5 - 0
Trio/Sources/Models/TrioSettings.swift

@@ -66,6 +66,7 @@ struct TrioSettings: JSON, Equatable, Encodable {
     var useLiveActivity: Bool = false
     var lockScreenView: LockScreenView = .simple
     var smartStackView: LockScreenView = .simple
+    var displayGlucoseForecasts: Bool = false
     var bolusShortcut: BolusShortcutLimit = .notAllowed
     var timeInRangeType: TimeInRangeType = .timeInTightRange
     var requireAdjustmentsConfirmation: Bool = false
@@ -311,6 +312,10 @@ extension TrioSettings: Decodable {
             settings.smartStackView = smartStackView
         }
 
+        if let displayGlucoseForecasts = try? container.decode(Bool.self, forKey: .displayGlucoseForecasts) {
+            settings.displayGlucoseForecasts = displayGlucoseForecasts
+        }
+
         if let bolusShortcut = try? container.decode(BolusShortcutLimit.self, forKey: .bolusShortcut) {
             settings.bolusShortcut = bolusShortcut
         }

+ 0 - 1
Trio/Sources/Modules/Adjustments/AdjustmentsStateModel.swift

@@ -70,7 +70,6 @@ extension Adjustments {
         var didSaveSettings: Bool = false
 
         // Core Data
-        let coredataContext = CoreDataStack.shared.newTaskContext()
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
         // Help Sheet

+ 3 - 2
Trio/Sources/Modules/DynamicSettings/DynamicSettingsStateModel.swift

@@ -39,8 +39,6 @@ extension DynamicSettings {
 
         var units: GlucoseUnits = .mgdL
 
-        let context = CoreDataStack.shared.newTaskContext()
-
         override func subscribe() {
             units = settingsManager.settings.units
 
@@ -102,6 +100,9 @@ extension DynamicSettings {
         /// - Returns: `true` if sufficient TDD data is available, otherwise `false`.
         /// - Throws: An error if the Core Data count operation fails.
         private func hasSufficientTDD() throws -> Bool {
+            let context = CoreDataStack.shared.newTaskContext()
+            context.name = "DynamicSettingsStateModel.hasSufficientTDD"
+
             var result = false
 
             context.performAndWait {

+ 0 - 2
Trio/Sources/Modules/History/HistoryStateModel.swift

@@ -14,8 +14,6 @@ extension History {
         @ObservationIgnored @Injected() var healthKitManager: HealthKitManager!
         @ObservationIgnored @Injected() var carbsStorage: CarbsStorage!
 
-        let coredataContext = CoreDataStack.shared.newTaskContext()
-
         var mode: Mode = .treatments
         var treatments: [Treatment] = []
         var manualGlucose: Decimal = 0

+ 18 - 28
Trio/Sources/Modules/Home/HomeStateModel+Setup/BatterySetup.swift

@@ -2,40 +2,30 @@ import CoreData
 import Foundation
 
 extension Home.StateModel {
-    func setupBatteryArray() {
-        Task {
-            do {
-                let ids = try await self.fetchBattery()
-                let batteryObjects: [OpenAPS_Battery] = try await CoreDataStack.shared
-                    .getNSManagedObject(with: ids, context: viewContext)
-                await updateBatteryArray(with: batteryObjects)
-            } catch {
-                debug(
-                    .default,
-                    "\(DebuggingIdentifiers.failed) Error setting up battery array: \(error)"
-                )
+    @MainActor func setupBatteryController() {
+        batteryControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateBatteryFromController()
             }
         }
-    }
 
-    private func fetchBattery() async throws -> [NSManagedObjectID] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: OpenAPS_Battery.self,
-            onContext: batteryFetchContext,
-            predicate: NSPredicate.predicateFor30MinAgo,
-            key: "date",
-            ascending: false
-        )
-
-        return try await batteryFetchContext.perform {
-            guard let fetchedResults = results as? [OpenAPS_Battery] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
-            return fetchedResults.map(\.objectID)
+        do {
+            try batteryController.performFetch()
+            updateBatteryFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform battery fetch: \(error)")
         }
     }
 
-    @MainActor private func updateBatteryArray(with objects: [OpenAPS_Battery]) {
+    @MainActor func updateBatteryFromController() {
+        guard let objects = batteryController.fetchedObjects else { return }
         batteryFromPersistence = objects
     }
+
+    /// Called from the `pumpDisplayState` sink, `settingsDidChange` and `pumpSettingsDidChange`.
+    func setupBatteryArray() {
+        Task { @MainActor in
+            updateBatteryFromController()
+        }
+    }
 }

+ 26 - 53
Trio/Sources/Modules/Home/HomeStateModel+Setup/CarbSetup.swift

@@ -2,74 +2,47 @@ import CoreData
 import Foundation
 
 extension Home.StateModel {
-    func setupCarbsArray() {
-        Task {
-            do {
-                let ids = try await self.fetchCarbs()
-                let carbObjects: [CarbEntryStored] = try await CoreDataStack.shared
-                    .getNSManagedObject(with: ids, context: viewContext)
-                await updateCarbsArray(with: carbObjects)
-            } catch {
-                debugPrint("\(DebuggingIdentifiers.failed) Error fetching carb objects: \(error) in \(#file):\(#line)")
-            }
-        }
-    }
+    // MARK: - Carbs
 
-    private func fetchCarbs() async throws -> [NSManagedObjectID] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: CarbEntryStored.self,
-            onContext: carbsFetchContext,
-            predicate: NSPredicate.carbsForChart,
-            key: "date",
-            ascending: false,
-            batchSize: 5
-        )
-
-        return try await carbsFetchContext.perform {
-            guard let fetchedResults = results as? [CarbEntryStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
+    @MainActor func setupCarbsController() {
+        carbsControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateCarbsFromController()
             }
+        }
 
-            return fetchedResults.map(\.objectID)
+        do {
+            try carbsController.performFetch()
+            updateCarbsFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform carbs fetch: \(error)")
         }
     }
 
-    @MainActor private func updateCarbsArray(with objects: [CarbEntryStored]) {
+    @MainActor func updateCarbsFromController() {
+        guard let objects = carbsController.fetchedObjects else { return }
         carbsFromPersistence = objects
     }
 
-    func setupFPUsArray() {
-        Task {
-            do {
-                let ids = try await self.fetchFPUs()
-                let fpuObjects: [CarbEntryStored] = try await CoreDataStack.shared
-                    .getNSManagedObject(with: ids, context: viewContext)
-                await updateFPUsArray(with: fpuObjects)
-            } catch {
-                debugPrint("\(DebuggingIdentifiers.failed) Error fetching FPU objects: \(error) in \(#file):\(#line)")
-            }
-        }
-    }
+    // MARK: - FPUs
 
-    private func fetchFPUs() async throws -> [NSManagedObjectID] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: CarbEntryStored.self,
-            onContext: fpuFetchContext,
-            predicate: NSPredicate.fpusForChart,
-            key: "date",
-            ascending: false
-        )
-
-        return try await fpuFetchContext.perform {
-            guard let fetchedResults = results as? [CarbEntryStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
+    @MainActor func setupFPUController() {
+        fpuControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateFPUsFromController()
             }
+        }
 
-            return fetchedResults.map(\.objectID)
+        do {
+            try fpuController.performFetch()
+            updateFPUsFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform FPU fetch: \(error)")
         }
     }
 
-    @MainActor private func updateFPUsArray(with objects: [CarbEntryStored]) {
+    @MainActor func updateFPUsFromController() {
+        guard let objects = fpuController.fetchedObjects else { return }
         fpusFromPersistence = objects
     }
 }

+ 47 - 92
Trio/Sources/Modules/Home/HomeStateModel+Setup/ChartAxisSetup.swift

@@ -1,111 +1,66 @@
+import CoreData
 import Foundation
 
 extension Home.StateModel {
-    func yAxisChartData(glucoseValues: [GlucoseStored]) {
-        // Capture the forecast values from `preprocessedData` on the main thread
-        Task { @MainActor in
-            let forecastValues = self.preprocessedData.map { Decimal($0.forecastValue.value) }
+    /// Recomputes the main glucose chart Y axis bounds from the fetched glucose objects.
+    /// Runs on the main actor since the inputs are viewContext managed objects.
+    @MainActor func updateGlucoseChartYAxis(glucoseValues: [GlucoseStored]) {
+        let glucoseMapped = glucoseValues.map { Decimal($0.glucose) }
+        let forecastValues = preprocessedData.map { Decimal($0.forecastValue.value) }
 
-            // Perform the glucose processing on the background context
-            glucoseFetchContext.perform {
-                let glucoseMapped = glucoseValues.map { Decimal($0.glucose) }
-
-                // Calculate min and max values for glucose and forecast
-                let minGlucose = glucoseMapped.min()
-                let maxGlucose = glucoseMapped.max()
-                let minForecast = forecastValues.min()
-                let maxForecast = forecastValues.max()
-
-                // Ensure all values exist, otherwise set default values
-                guard let minGlucose = minGlucose, let maxGlucose = maxGlucose else {
-                    Task {
-                        await self.updateChartBounds(minValue: 39, maxValue: 200)
-                    }
-                    return
-                }
+        // Ensure all values exist, otherwise set default values
+        guard let minGlucose = glucoseMapped.min(), let maxGlucose = glucoseMapped.max() else {
+            minYAxisValue = 39
+            maxYAxisValue = 200
+            return
+        }
 
-                // Adjust max forecast to be no more than 50 over max glucose
-                let adjustedMaxForecast = min(maxForecast ?? maxGlucose + 50, maxGlucose + 50)
-                let minOverall = min(minGlucose, minForecast ?? minGlucose)
-                let maxOverall = max(maxGlucose, adjustedMaxForecast)
+        let minForecast = forecastValues.min()
+        let maxForecast = forecastValues.max()
 
-                var maxYValue = Decimal(200)
-                if maxOverall > 200, maxOverall <= 225 {
-                    maxYValue = Decimal(250)
-                } else if maxOverall > 225, maxOverall <= 275 {
-                    maxYValue = Decimal(300)
-                } else if maxOverall > 275, maxOverall <= 325 {
-                    maxYValue = Decimal(350)
-                } else if maxOverall > 325 {
-                    maxYValue = Decimal(400)
-                }
+        // Adjust max forecast to be no more than 50 over max glucose
+        let adjustedMaxForecast = min(maxForecast ?? maxGlucose + 50, maxGlucose + 50)
+        let minOverall = min(minGlucose, minForecast ?? minGlucose)
+        let maxOverall = max(maxGlucose, adjustedMaxForecast)
 
-                // Update the chart bounds on the main thread
-                Task {
-                    await self.updateChartBounds(minValue: minOverall, maxValue: maxYValue)
-                }
-            }
+        var maxYValue: Decimal = 200
+        if maxOverall > 200, maxOverall <= 225 {
+            maxYValue = 250
+        } else if maxOverall > 225, maxOverall <= 275 {
+            maxYValue = 300
+        } else if maxOverall > 275, maxOverall <= 325 {
+            maxYValue = 350
+        } else if maxOverall > 325 {
+            maxYValue = 400
         }
-    }
 
-    @MainActor private func updateChartBounds(minValue: Decimal, maxValue: Decimal) async {
-        minYAxisValue = minValue
-        maxYAxisValue = maxValue
+        minYAxisValue = minOverall
+        maxYAxisValue = maxYValue
     }
 
-    func yAxisChartDataCobChart(determinations: [[String: Any]]) {
-        determinationFetchContext.perform {
-            // Map the COB values from the dictionary results
-            let cobMapped = determinations.compactMap { entry in
-                // First cast to Int16, then convert to Decimal
-                if let cobValue = entry["cob"] as? Int16 {
-                    return Decimal(cobValue)
-                }
-                return nil
-            }
-            let maxCob = cobMapped.max()
+    /// Recomputes the COB chart Y axis bounds from the fetched determination objects.
+    @MainActor func yAxisChartDataCobChart(determinations: [OrefDetermination]) {
+        let cobMapped = determinations.map { Decimal($0.cob) }
 
-            // Ensure the result exists or set default values
-            if let maxCob = maxCob {
-                let calculatedMax = maxCob == 0 ? 20 : maxCob + 20
-                Task {
-                    await self.updateCobChartBounds(minValue: 0, maxValue: calculatedMax)
-                }
-            } else {
-                Task {
-                    await self.updateCobChartBounds(minValue: 0, maxValue: 20)
-                }
-            }
+        if let maxCob = cobMapped.max() {
+            minValueCobChart = 0
+            maxValueCobChart = maxCob == 0 ? 20 : maxCob + 20
+        } else {
+            minValueCobChart = 0
+            maxValueCobChart = 20
         }
     }
 
-    @MainActor private func updateCobChartBounds(minValue: Decimal, maxValue: Decimal) {
-        minValueCobChart = minValue
-        maxValueCobChart = maxValue
-    }
-
-    func yAxisChartDataIobChart(determinations: [[String: Any]]) {
-        determinationFetchContext.perform {
-            // Map the IOB values from the fetched dictionaries
-            let iobMapped = determinations.compactMap { ($0["iob"] as? NSDecimalNumber)?.decimalValue }
-            let minIob = iobMapped.min()
-            let maxIob = iobMapped.max()
+    /// Recomputes the IOB chart Y axis bounds from the fetched determination objects.
+    @MainActor func yAxisChartDataIobChart(determinations: [OrefDetermination]) {
+        let iobMapped = determinations.compactMap { $0.iob?.decimalValue }
 
-            // Ensure min and max IOB values exist, or set defaults
-            if let minIob = minIob, let maxIob = maxIob {
-                Task {
-                    await self.updateIobChartBounds(minValue: minIob, maxValue: maxIob)
-                }
-            } else {
-                Task {
-                    await self.updateIobChartBounds(minValue: 0, maxValue: 5)
-                }
-            }
+        if let minIob = iobMapped.min(), let maxIob = iobMapped.max() {
+            minValueIobChart = minIob
+            maxValueIobChart = maxIob
+        } else {
+            minValueIobChart = 0
+            maxValueIobChart = 5
         }
     }
-
-    @MainActor private func updateIobChartBounds(minValue: Decimal, maxValue: Decimal) async {
-        minValueIobChart = minValue
-        maxValueIobChart = maxValue
-    }
 }

+ 13 - 41
Trio/Sources/Modules/Home/HomeStateModel+Setup/CurrentTDDSetup.swift

@@ -2,51 +2,23 @@ import CoreData
 import Foundation
 
 extension Home.StateModel {
-    func setupTDDArray() {
-        Task {
-            do {
-                // Get the NSManagedObjectIDs
-                let tddObjectIds = try await fetchTDDIDs()
-
-                // Get the NSManagedObjects and map them to TDD on the Main Thread
-                try await updateTDDArray(with: tddObjectIds, keyPath: \.fetchedTDDs)
-            } catch {
-                debug(.default, "\(DebuggingIdentifiers.failed) failed to fetch TDDs: \(error)")
+    @MainActor func setupTDDController() {
+        tddControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateTDDFromController()
             }
         }
-    }
 
-    @MainActor private func updateTDDArray(
-        with IDs: [NSManagedObjectID],
-        keyPath: ReferenceWritableKeyPath<Home.StateModel, [TDD]>
-    ) async throws {
-        let tddObjects: [TDD] = try await CoreDataStack.shared
-            .getNSManagedObject(with: IDs, context: viewContext)
-            .compactMap { managedObject in
-                // Safely extract date and total as optional
-                let timestamp = managedObject.value(forKey: "date") as? Date
-                let totalDailyDose = (managedObject.value(forKey: "total") as? NSNumber)?.decimalValue
-                return TDD(totalDailyDose: totalDailyDose, timestamp: timestamp)
-            }
-        self[keyPath: keyPath] = tddObjects
+        do {
+            try tddController.performFetch()
+            updateTDDFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform TDD fetch: \(error)")
+        }
     }
 
-    private func fetchTDDIDs() async throws -> [NSManagedObjectID] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: TDDStored.self,
-            onContext: tddFetchContext,
-            predicate: NSPredicate.predicateForOneDayAgo,
-            key: "date",
-            ascending: false,
-            fetchLimit: 1,
-            propertiesToFetch: ["total", "date", "objectID"]
-        )
-
-        return await tddFetchContext.perform {
-            guard let fetchedResults = results as? [[String: Any]] else {
-                return []
-            }
-            return fetchedResults.compactMap { $0["objectID"] as? NSManagedObjectID }
-        }
+    @MainActor private func updateTDDFromController() {
+        guard let objects = tddController.fetchedObjects else { return }
+        fetchedTDDs = objects.map { TDD(totalDailyDose: $0.total?.decimalValue, timestamp: $0.date) }
     }
 }

+ 47 - 45
Trio/Sources/Modules/Home/HomeStateModel+Setup/DeterminationSetup.swift

@@ -2,63 +2,65 @@ import CoreData
 import Foundation
 
 extension Home.StateModel {
-    func setupDeterminationsArray() {
-        Task {
-            do {
-                // Get the NSManagedObjectIDs
-                async let enactedObjectIds = determinationStorage
-                    .fetchLastDeterminationObjectID(predicate: NSPredicate.enactedDetermination)
-                async let enactedAndNonEnactedObjectIds = fetchCobAndIob()
+    // MARK: - Enacted Determination
 
-                let enactedIDs = try await enactedObjectIds
-                let enactedAndNonEnactedIds = try await enactedAndNonEnactedObjectIds
-
-                // Get the NSManagedObjects and return them on the Main Thread
-                try await updateDeterminationsArray(with: enactedIDs, keyPath: \.determinationsFromPersistence)
-                try await updateDeterminationsArray(with: enactedAndNonEnactedIds, keyPath: \.enactedAndNonEnactedDeterminations)
+    @MainActor func setupEnactedDeterminationController() {
+        enactedDeterminationControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.scheduleForecastUpdate()
+            }
+        }
 
-                await updateForecastData()
-            } catch let error as CoreDataError {
-                debug(.default, "Core Data error in setupDeterminationsArray: \(error)")
-            } catch {
-                debug(.default, "Unexpected error in setupDeterminationsArray: \(error)")
+        do {
+            try enactedDeterminationController.performFetch()
+            updateEnactedDeterminationFromController()
+            // Initial population; assigned to `forecastUpdateTask` so a change arriving
+            // during startup cancels it instead of racing it.
+            forecastUpdateTask = Task { @MainActor in
+                await self.updateForecastData()
             }
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform enacted determination fetch: \(error)")
         }
     }
 
-    @MainActor private func updateDeterminationsArray(
-        with IDs: [NSManagedObjectID],
-        keyPath: ReferenceWritableKeyPath<Home.StateModel, [OrefDetermination]>
-    ) async throws {
-        // Fetch the objects off the main thread
-        let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared
-            .getNSManagedObject(with: IDs, context: viewContext)
+    /// Coalesces rapid determination changes — each cancels the previous pending recompute.
+    @MainActor func scheduleForecastUpdate() {
+        forecastUpdateTask?.cancel()
+        forecastUpdateTask = Task { @MainActor in
+            try? await Task.sleep(for: .milliseconds(150))
+            guard !Task.isCancelled else { return }
+            updateEnactedDeterminationFromController()
+            await updateForecastData()
+        }
+    }
 
-        // Update the array on the main thread
-        self[keyPath: keyPath] = determinationObjects
+    @MainActor private func updateEnactedDeterminationFromController() {
+        guard let objects = enactedDeterminationController.fetchedObjects else { return }
+        determinationsFromPersistence = objects
     }
 
-    // Custom fetch to more efficiently filter only for cob and iob
-    private func fetchCobAndIob() async throws -> [NSManagedObjectID] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: OrefDetermination.self,
-            onContext: determinationFetchContext,
-            predicate: NSPredicate.determinationsForCobIobCharts,
-            key: "deliverAt",
-            ascending: false,
-            batchSize: 50,
-            propertiesToFetch: ["cob", "iob", "deliverAt", "objectID"]
-        )
+    // MARK: - Determinations for COB/IOB Charts
 
-        return try await determinationFetchContext.perform {
-            guard let fetchedResults = results as? [[String: Any]] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
+    @MainActor func setupDeterminationController() {
+        determinationControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateDeterminationsFromController()
             }
+        }
 
-            // Update Chart Scales
-            self.yAxisChartDataCobChart(determinations: fetchedResults)
-            self.yAxisChartDataIobChart(determinations: fetchedResults)
-            return fetchedResults.compactMap { $0["objectID"] as? NSManagedObjectID }
+        do {
+            try determinationController.performFetch()
+            updateDeterminationsFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform determination fetch: \(error)")
         }
     }
+
+    @MainActor func updateDeterminationsFromController() {
+        guard let objects = determinationController.fetchedObjects else { return }
+        enactedAndNonEnactedDeterminations = objects
+        yAxisChartDataCobChart(determinations: objects)
+        yAxisChartDataIobChart(determinations: objects)
+    }
 }

+ 21 - 0
Trio/Sources/Modules/Home/HomeStateModel+Setup/ForecastSetup.swift

@@ -15,6 +15,9 @@ extension Home.StateModel {
                 return []
             }
 
+            let taskContext = CoreDataStack.shared.newTaskContext()
+            taskContext.name = "HomeStateModel.preprocessForecastData"
+
             // Fetch complete forecast hierarchy with prefetched values
             return try await determinationStorage.fetchForecastHierarchy(
                 for: determination.objectID,
@@ -30,12 +33,28 @@ extension Home.StateModel {
     }
 
     // Update forecast data and UI on the main thread
+    //
+    // Runs inside `forecastUpdateTask`; cancellation is checked after every suspension
+    // point so a superseded run cannot overwrite fresher results.
     @MainActor func updateForecastData() async {
         let forecastDataIDs = await preprocessForecastData()
 
+        guard !Task.isCancelled else { return }
+
         var allForecastValues = [[Int]]()
         var preprocessedData = [(id: UUID, forecast: Forecast, forecastValue: ForecastValue)]()
 
+        // Prefetch all Forecasts with their forecastValues into viewContext in a single IN-query
+        // to avoid N+1 individual SELECTs when materializing via existingObject below.
+        let forecastObjectIDs = forecastDataIDs.map(\.forecastID)
+        if !forecastObjectIDs.isEmpty {
+            let prefetchRequest = NSFetchRequest<Forecast>(entityName: "Forecast")
+            prefetchRequest.predicate = NSPredicate(format: "SELF IN %@", forecastObjectIDs)
+            prefetchRequest.relationshipKeyPathsForPrefetching = ["forecastValues"]
+            prefetchRequest.returnsObjectsAsFaults = false
+            _ = try? viewContext.fetch(prefetchRequest)
+        }
+
         // Process prefetched data directly
         for data in forecastDataIDs {
             if let forecast = try? viewContext.existingObject(with: data.forecastID) as? Forecast {
@@ -83,6 +102,8 @@ extension Home.StateModel {
             return (minForecast, maxForecast)
         }.value
 
+        guard !Task.isCancelled else { return }
+
         minForecast = minResult
         maxForecast = maxResult
     }

+ 19 - 34
Trio/Sources/Modules/Home/HomeStateModel+Setup/GlucoseSetup.swift

@@ -2,47 +2,32 @@ import CoreData
 import Foundation
 
 extension Home.StateModel {
-    func setupGlucoseArray() {
-        Task {
-            do {
-                let ids = try await self.fetchGlucose()
-                let glucoseObjects: [GlucoseStored] = try await CoreDataStack.shared
-                    .getNSManagedObject(with: ids, context: viewContext)
-                await updateGlucoseArray(with: glucoseObjects)
-            } catch {
-                debug(
-                    .default,
-                    "\(DebuggingIdentifiers.failed) Error setting up glucose array: \(error)"
-                )
+    @MainActor func setupGlucoseController() {
+        glucoseControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateGlucoseFromController()
             }
         }
-    }
-
-    private func fetchGlucose() async throws -> [NSManagedObjectID] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: GlucoseStored.self,
-            onContext: glucoseFetchContext,
-            predicate: NSPredicate.glucose,
-            key: "date",
-            ascending: true,
-            batchSize: 50
-        )
-
-        return try await glucoseFetchContext.perform {
-            guard let fetchedResults = results as? [GlucoseStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
 
-            // Update Main Chart Y Axis Values
-            // Perform everything on "context" to be thread safe
-            self.yAxisChartData(glucoseValues: fetchedResults)
-
-            return fetchedResults.map(\.objectID)
+        do {
+            try glucoseController.performFetch()
+            updateGlucoseFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform glucose fetch: \(error)")
         }
     }
 
-    @MainActor private func updateGlucoseArray(with objects: [GlucoseStored]) {
+    @MainActor func updateGlucoseFromController() {
+        guard let objects = glucoseController.fetchedObjects else { return }
         glucoseFromPersistence = objects
         latestTwoGlucoseValues = Array(objects.suffix(2))
+        updateGlucoseChartYAxis(glucoseValues: objects)
+    }
+
+    /// Called from `MainChartView` on `.onChange(of: units)` to recompute the glucose-derived chart state.
+    func setupGlucoseArray() {
+        Task { @MainActor in
+            updateGlucoseFromController()
+        }
     }
 }

+ 28 - 57
Trio/Sources/Modules/Home/HomeStateModel+Setup/OverrideSetup.swift

@@ -2,81 +2,52 @@ import CoreData
 import Foundation
 
 extension Home.StateModel {
-    // Setup Overrides
-    func setupOverrides() {
-        Task {
-            do {
-                let ids = try await self.fetchOverrides()
-                let overrideObjects: [OverrideStored] = try await CoreDataStack.shared
-                    .getNSManagedObject(with: ids, context: viewContext)
-                await updateOverrideArray(with: overrideObjects)
-            } catch let error as CoreDataError {
-                debug(.default, "Core Data error in setupOverrides: \(error)")
-            } catch {
-                debug(.default, "Unexpected error in setupOverrides: \(error)")
+    // MARK: - Overrides
+
+    @MainActor func setupOverrideController() {
+        overrideControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateOverridesFromController()
             }
         }
-    }
 
-    private func fetchOverrides() async throws -> [NSManagedObjectID] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: OverrideStored.self,
-            onContext: overrideFetchContext,
-            predicate: NSPredicate.lastActiveOverride, // this predicate filters for all Overrides within the last 24h
-            key: "date",
-            ascending: false
-        )
-
-        return try await overrideFetchContext.perform {
-            guard let fetchedResults = results as? [OverrideStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
-            return fetchedResults.map(\.objectID)
+        do {
+            try overrideController.performFetch()
+            updateOverridesFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform override fetch: \(error)")
         }
     }
 
-    @MainActor private func updateOverrideArray(with objects: [OverrideStored]) {
+    @MainActor private func updateOverridesFromController() {
+        guard let objects = overrideController.fetchedObjects else { return }
         overrides = objects
     }
 
-    // Setup expired Overrides
-    func setupOverrideRunStored() {
-        Task {
-            do {
-                let ids = try await self.fetchOverrideRunStored()
-                let overrideRunObjects: [OverrideRunStored] = try await CoreDataStack.shared
-                    .getNSManagedObject(with: ids, context: viewContext)
-                await updateOverrideRunStoredArray(with: overrideRunObjects)
-            } catch let error as CoreDataError {
-                debug(.default, "Core Data error in setupOverrideRunStored: \(error)")
-            } catch {
-                debug(.default, "Unexpected error in setupOverrideRunStored: \(error)")
+    // MARK: - Override Runs
+
+    @MainActor func setupOverrideRunController() {
+        overrideRunControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateOverrideRunsFromController()
             }
         }
-    }
 
-    private func fetchOverrideRunStored() async throws -> [NSManagedObjectID] {
-        let predicate = NSPredicate(format: "startDate >= %@", Date.oneDayAgo as NSDate)
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: OverrideRunStored.self,
-            onContext: overrideFetchContext,
-            predicate: predicate,
-            key: "startDate",
-            ascending: false
-        )
-
-        return try await overrideFetchContext.perform {
-            guard let fetchedResults = results as? [OverrideRunStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
-            return fetchedResults.map(\.objectID)
+        do {
+            try overrideRunController.performFetch()
+            updateOverrideRunsFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform override run fetch: \(error)")
         }
     }
 
-    @MainActor private func updateOverrideRunStoredArray(with objects: [OverrideRunStored]) {
+    @MainActor func updateOverrideRunsFromController() {
+        guard let objects = overrideRunController.fetchedObjects else { return }
         overrideRunStored = objects
     }
 
+    // MARK: - Override Actions
+
     /// Cancels the running Override, creates an entry in the OverrideRunStored Core Data entity and posts a custom notification so that the AdjustmentsView gets updated
     @MainActor func cancelOverride(withID id: NSManagedObjectID) async {
         do {

+ 34 - 69
Trio/Sources/Modules/Home/HomeStateModel+Setup/PumpHistorySetup.swift

@@ -2,94 +2,59 @@ import CoreData
 import Foundation
 
 extension Home.StateModel {
-    func setupInsulinArray() {
-        Task {
-            do {
-                let ids = try await self.fetchInsulin()
-                let insulinObjects: [PumpEventStored] = try await CoreDataStack.shared
-                    .getNSManagedObject(with: ids, context: viewContext)
-                await updateInsulinArray(with: insulinObjects)
-            } catch {
-                debug(
-                    .default,
-                    "\(DebuggingIdentifiers.failed) Error setting up insulin array: \(error)"
-                )
+    // MARK: - Insulin / Pump History
+
+    @MainActor func setupInsulinController() {
+        insulinControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                guard let self else { return }
+                self.updateInsulinFromController()
+                self.displayPumpStatusHighlightMessage()
+                self.displayPumpStatusBadge()
             }
         }
-    }
-
-    private func fetchInsulin() async throws -> [NSManagedObjectID] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: PumpEventStored.self,
-            onContext: pumpHistoryFetchContext,
-            predicate: NSPredicate.pumpHistoryLast24h,
-            key: "timestamp",
-            ascending: true,
-            batchSize: 30
-        )
-
-        return try await pumpHistoryFetchContext.perform {
-            guard let pumpEvents = results as? [PumpEventStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
 
-            return pumpEvents.map(\.objectID)
+        do {
+            try insulinController.performFetch()
+            updateInsulinFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform insulin fetch: \(error)")
         }
     }
 
-    @MainActor private func updateInsulinArray(with insulinObjects: [PumpEventStored]) {
-        insulinFromPersistence = insulinObjects
+    @MainActor func updateInsulinFromController() {
+        guard let objects = insulinController.fetchedObjects else { return }
+        insulinFromPersistence = objects
 
         manualTempBasal = apsManager.isManualTempBasal
-        tempBasals = insulinFromPersistence.filter { $0.tempBasal != nil }
-
-        suspendAndResumeEvents = insulinFromPersistence.filter {
+        tempBasals = objects.filter { $0.tempBasal != nil }
+        suspendAndResumeEvents = objects.filter {
             $0.type == EventType.pumpSuspend.rawValue || $0.type == EventType.pumpResume.rawValue
         }
     }
 
-    // Setup Last Bolus to display the bolus progress bar
-    // The predicate filters out all external boluses to prevent the progress bar from displaying the amount of an external bolus when an external bolus is added after a pump bolus
-    func setupLastBolus() {
-        Task {
-            do {
-                guard let id = try await self.fetchLastBolus() else { return }
-                await updateLastBolus(with: id)
-            } catch {
-                debug(
-                    .default,
-                    "\(DebuggingIdentifiers.failed) Error setting up last bolus: \(error)"
-                )
-            }
-        }
-    }
+    // MARK: - Last Bolus
 
-    func fetchLastBolus() async throws -> NSManagedObjectID? {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: PumpEventStored.self,
-            onContext: pumpHistoryFetchContext,
-            predicate: NSPredicate.lastPumpBolus,
-            key: "timestamp",
-            ascending: false,
-            fetchLimit: 1
-        )
+    //
+    // Drives the bolus progress bar. The predicate filters out external boluses so the progress bar
+    // does not display the amount of an external bolus added after a pump bolus.
 
-        return try await pumpHistoryFetchContext.perform {
-            guard let fetchedResults = results as? [PumpEventStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
+    @MainActor func setupLastBolusController() {
+        lastBolusControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateLastBolusFromController()
             }
-
-            return fetchedResults.map(\.objectID).first
         }
-    }
 
-    @MainActor private func updateLastBolus(with ID: NSManagedObjectID) {
         do {
-            lastPumpBolus = try viewContext.existingObject(with: ID) as? PumpEventStored
+            try lastBolusController.performFetch()
+            updateLastBolusFromController()
         } catch {
-            debugPrint(
-                "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the insulin array: \(error)"
-            )
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform last bolus fetch: \(error)")
         }
     }
+
+    @MainActor private func updateLastBolusFromController() {
+        lastPumpBolus = lastBolusController.fetchedObjects?.first
+    }
 }

+ 28 - 58
Trio/Sources/Modules/Home/HomeStateModel+Setup/TempTargetSetup.swift

@@ -2,82 +2,52 @@ import CoreData
 import Foundation
 
 extension Home.StateModel {
-    func setupTempTargetsStored() {
-        Task {
-            do {
-                let ids = try await self.fetchTempTargets()
-                let tempTargetObjects: [TempTargetStored] = try await CoreDataStack.shared
-                    .getNSManagedObject(with: ids, context: viewContext)
-                await updateTempTargetsArray(with: tempTargetObjects)
-            } catch {
-                debug(
-                    .default,
-                    "\(DebuggingIdentifiers.failed) Error setting up tempTargetStored: \(error)"
-                )
+    // MARK: - Temp Targets
+
+    @MainActor func setupTempTargetController() {
+        tempTargetControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateTempTargetsFromController()
             }
         }
-    }
 
-    private func fetchTempTargets() async throws -> [NSManagedObjectID] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: TempTargetStored.self,
-            onContext: tempTargetFetchContext,
-            predicate: NSPredicate.tempTargetsForMainChart,
-            key: "date",
-            ascending: false
-        )
-
-        return try await tempTargetFetchContext.perform {
-            guard let fetchedResults = results as? [TempTargetStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
-            return fetchedResults.map(\.objectID)
+        do {
+            try tempTargetController.performFetch()
+            updateTempTargetsFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform temp target fetch: \(error)")
         }
     }
 
-    @MainActor private func updateTempTargetsArray(with objects: [TempTargetStored]) {
+    @MainActor func updateTempTargetsFromController() {
+        guard let objects = tempTargetController.fetchedObjects else { return }
         tempTargetStored = objects
     }
 
-    // Setup expired TempTargets
-    func setupTempTargetsRunStored() {
-        Task {
-            do {
-                let ids = try await self.fetchTempTargetRunStored()
-                let tempTargetRunObjects: [TempTargetRunStored] = try await CoreDataStack.shared
-                    .getNSManagedObject(with: ids, context: viewContext)
-                await updateTempTargetRunStoredArray(with: tempTargetRunObjects)
-            } catch {
-                debug(
-                    .default,
-                    "\(DebuggingIdentifiers.failed) Error setting up temp targetsRunStored: \(error)"
-                )
+    // MARK: - Temp Target Runs
+
+    @MainActor func setupTempTargetRunController() {
+        tempTargetRunControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateTempTargetRunsFromController()
             }
         }
-    }
 
-    private func fetchTempTargetRunStored() async throws -> [NSManagedObjectID] {
-        let predicate = NSPredicate(format: "startDate >= %@", Date.oneDayAgo as NSDate)
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: TempTargetRunStored.self,
-            onContext: tempTargetFetchContext,
-            predicate: predicate,
-            key: "startDate",
-            ascending: false
-        )
-
-        return try await tempTargetFetchContext.perform {
-            guard let fetchedResults = results as? [TempTargetRunStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
-            return fetchedResults.map(\.objectID)
+        do {
+            try tempTargetRunController.performFetch()
+            updateTempTargetRunsFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform temp target run fetch: \(error)")
         }
     }
 
-    @MainActor private func updateTempTargetRunStoredArray(with objects: [TempTargetRunStored]) {
+    @MainActor func updateTempTargetRunsFromController() {
+        guard let objects = tempTargetRunController.fetchedObjects else { return }
         tempTargetRunStored = objects
     }
 
+    // MARK: - Temp Target Actions
+
     @MainActor func cancelTempTarget(withID id: NSManagedObjectID) async {
         do {
             guard let profileToCancel = try viewContext.existingObject(with: id) as? TempTargetStored else { return }

+ 300 - 130
Trio/Sources/Modules/Home/HomeStateModel.swift

@@ -100,6 +100,7 @@ extension Home {
         var tempBasals: [PumpEventStored] = []
         var suspendAndResumeEvents: [PumpEventStored] = []
         var batteryFromPersistence: [OpenAPS_Battery] = []
+        var bolusStatus: BolusStatus = .noBolus
         var lastPumpBolus: PumpEventStored?
         var overrides: [OverrideStored] = []
         var overrideRunStored: [OverrideRunStored] = []
@@ -138,23 +139,272 @@ extension Home {
         var minValueIobChart: Decimal = 0
         var maxValueIobChart: Decimal = 5
 
-        let taskContext = CoreDataStack.shared.newTaskContext()
-        let glucoseFetchContext = CoreDataStack.shared.newTaskContext()
-        let carbsFetchContext = CoreDataStack.shared.newTaskContext()
-        let fpuFetchContext = CoreDataStack.shared.newTaskContext()
-        let determinationFetchContext = CoreDataStack.shared.newTaskContext()
-        let tddFetchContext = CoreDataStack.shared.newTaskContext()
-        let pumpHistoryFetchContext = CoreDataStack.shared.newTaskContext()
-        let overrideFetchContext = CoreDataStack.shared.newTaskContext()
-        let tempTargetFetchContext = CoreDataStack.shared.newTaskContext()
-        let batteryFetchContext = CoreDataStack.shared.newTaskContext()
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
-        // Queue for handling Core Data change notifications
-        private let queue = DispatchQueue(label: "HomeStateModel.queue", qos: .userInitiated)
-        private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
+        // MARK: - NSFetchedResultsControllers
+
+        //
+        // Each Core Data backed array on this state model is driven by an NSFetchedResultsController
+        // bound to the viewContext. The controllers keep their `fetchedObjects` continuously in sync
+        // with the viewContext (which in turn is fed by the persistent history merge in CoreDataStack)
+        // and notify us via their delegate's `onContentChange` closure. This replaces the previous
+        // hand-rolled `changedObjectsOnManagedObjectContextDidSavePublisher` + re-fetch approach.
+
+        @ObservationIgnored let glucoseControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var glucoseController: NSFetchedResultsController<GlucoseStored> = {
+            let request = NSFetchRequest<GlucoseStored>(entityName: "GlucoseStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \GlucoseStored.date, ascending: true)]
+            request.predicate = NSPredicate.glucose
+            request.fetchBatchSize = 50
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = glucoseControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let carbsControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var carbsController: NSFetchedResultsController<CarbEntryStored> = {
+            let request = NSFetchRequest<CarbEntryStored>(entityName: "CarbEntryStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \CarbEntryStored.date, ascending: false)]
+            request.predicate = NSPredicate.carbsForChart
+            request.fetchBatchSize = 5
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = carbsControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let fpuControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var fpuController: NSFetchedResultsController<CarbEntryStored> = {
+            let request = NSFetchRequest<CarbEntryStored>(entityName: "CarbEntryStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \CarbEntryStored.date, ascending: false)]
+            request.predicate = NSPredicate.fpusForChart
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = fpuControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let enactedDeterminationControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var enactedDeterminationController: NSFetchedResultsController<OrefDetermination> =
+            {
+                let request = NSFetchRequest<OrefDetermination>(entityName: "OrefDetermination")
+                request.sortDescriptors = [NSSortDescriptor(keyPath: \OrefDetermination.deliverAt, ascending: false)]
+                request.predicate = NSPredicate.enactedDetermination
+                request.fetchLimit = 1
+                let controller = NSFetchedResultsController(
+                    fetchRequest: request,
+                    managedObjectContext: viewContext,
+                    sectionNameKeyPath: nil,
+                    cacheName: nil
+                )
+                controller.delegate = enactedDeterminationControllerDelegate
+                return controller
+            }()
+
+        @ObservationIgnored let determinationControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var determinationController: NSFetchedResultsController<OrefDetermination> = {
+            let request = NSFetchRequest<OrefDetermination>(entityName: "OrefDetermination")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \OrefDetermination.deliverAt, ascending: false)]
+            request.predicate = NSPredicate.determinationsForCobIobCharts
+            request.fetchBatchSize = 50
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = determinationControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let insulinControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var insulinController: NSFetchedResultsController<PumpEventStored> = {
+            let request = NSFetchRequest<PumpEventStored>(entityName: "PumpEventStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \PumpEventStored.timestamp, ascending: true)]
+            request.predicate = NSPredicate.pumpHistoryLast24h
+            request.fetchBatchSize = 30
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = insulinControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let lastBolusControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var lastBolusController: NSFetchedResultsController<PumpEventStored> = {
+            let request = NSFetchRequest<PumpEventStored>(entityName: "PumpEventStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \PumpEventStored.timestamp, ascending: false)]
+            request.predicate = NSPredicate.lastPumpBolus
+            request.fetchLimit = 1
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = lastBolusControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let overrideControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var overrideController: NSFetchedResultsController<OverrideStored> = {
+            let request = NSFetchRequest<OverrideStored>(entityName: "OverrideStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \OverrideStored.date, ascending: false)]
+            request.predicate = NSPredicate.lastActiveOverride
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = overrideControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let overrideRunControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var overrideRunController: NSFetchedResultsController<OverrideRunStored> = {
+            let request = NSFetchRequest<OverrideRunStored>(entityName: "OverrideRunStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \OverrideRunStored.startDate, ascending: false)]
+            request.predicate = NSPredicate.predicateForStartDateOneDayAgo
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = overrideRunControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let tempTargetControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var tempTargetController: NSFetchedResultsController<TempTargetStored> = {
+            let request = NSFetchRequest<TempTargetStored>(entityName: "TempTargetStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \TempTargetStored.date, ascending: false)]
+            request.predicate = NSPredicate.tempTargetsForMainChart
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = tempTargetControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let tempTargetRunControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var tempTargetRunController: NSFetchedResultsController<TempTargetRunStored> = {
+            let request = NSFetchRequest<TempTargetRunStored>(entityName: "TempTargetRunStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \TempTargetRunStored.startDate, ascending: false)]
+            request.predicate = NSPredicate.predicateForStartDateOneDayAgo
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = tempTargetRunControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let batteryControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var batteryController: NSFetchedResultsController<OpenAPS_Battery> = {
+            let request = NSFetchRequest<OpenAPS_Battery>(entityName: "OpenAPS_Battery")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \OpenAPS_Battery.date, ascending: false)]
+            request.predicate = NSPredicate.predicateFor30MinAgo
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = batteryControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let tddControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var tddController: NSFetchedResultsController<TDDStored> = {
+            let request = NSFetchRequest<TDDStored>(entityName: "TDDStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \TDDStored.date, ascending: false)]
+            request.predicate = NSPredicate.predicateForOneDayAgo
+            request.fetchLimit = 1
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = tddControllerDelegate
+            return controller
+        }()
+
+        // MARK: - Fetch window re-anchoring
+
+        //
+        // Window predicates freeze their anchor at build time, so the unbounded controllers
+        // are re-anchored to "now" on foreground. fetchLimit-1 and override controllers keep
+        // the launch anchor on purpose; temp targets must re-anchor ("date >= now" disjunct).
+
+        /// Called on `willEnterForegroundNotification`; idempotent at launch.
+        @MainActor func reanchorFetchWindows() {
+            reanchor(glucoseController, with: NSPredicate.glucose) {
+                self.updateGlucoseFromController()
+                // Re-sync the chart domain even if no new reading arrived while backgrounded.
+                self.updateStartEndMarkers()
+            }
+            reanchor(carbsController, with: NSPredicate.carbsForChart) { self.updateCarbsFromController() }
+            reanchor(fpuController, with: NSPredicate.fpusForChart) { self.updateFPUsFromController() }
+            reanchor(determinationController, with: NSPredicate.determinationsForCobIobCharts) {
+                self.updateDeterminationsFromController()
+            }
+            reanchor(insulinController, with: NSPredicate.pumpHistoryLast24h) { self.updateInsulinFromController() }
+            reanchor(overrideRunController, with: NSPredicate.predicateForStartDateOneDayAgo) {
+                self.updateOverrideRunsFromController()
+            }
+            reanchor(tempTargetController, with: NSPredicate.tempTargetsForMainChart) {
+                self.updateTempTargetsFromController()
+            }
+            reanchor(tempTargetRunController, with: NSPredicate.predicateForStartDateOneDayAgo) {
+                self.updateTempTargetRunsFromController()
+            }
+            reanchor(batteryController, with: NSPredicate.predicateFor30MinAgo) { self.updateBatteryFromController() }
+        }
+
+        @MainActor private func reanchor<T: NSFetchRequestResult>(
+            _ controller: NSFetchedResultsController<T>,
+            with predicate: NSPredicate,
+            republish: () -> Void
+        ) {
+            controller.fetchRequest.predicate = predicate
+            do {
+                try controller.performFetch()
+                republish()
+            } catch {
+                debug(.default, "\(DebuggingIdentifiers.failed) Failed to re-anchor fetch window for \(T.self): \(error)")
+            }
+        }
+
         private var subscriptions = Set<AnyCancellable>()
 
+        /// Debounces the forecast recompute — the most expensive `onContentChange` callback,
+        /// which a manual re-determine fires twice in quick succession.
+        @ObservationIgnored var forecastUpdateTask: Task<Void, Never>?
+
         typealias PumpEvent = PumpEventStored.EventType
 
         override init() {
@@ -162,14 +412,7 @@ extension Home {
         }
 
         override func subscribe() {
-            coreDataPublisher =
-                changedObjectsOnManagedObjectContextDidSavePublisher()
-                    .receive(on: queue)
-                    .share()
-                    .eraseToAnyPublisher()
-
             registerSubscribers()
-            registerHandlers()
 
             // Parallelize Setup functions
             setupHomeViewConcurrently()
@@ -183,33 +426,25 @@ extension Home {
                 await self.setupCGMSettings()
                 self.registerObservers()
 
+                // Set up the NSFetchedResultsControllers. These are bound to the viewContext,
+                // so `performFetch` and the initial population must run on the main actor.
+                await self.setupGlucoseController()
+                await self.setupCarbsController()
+                await self.setupFPUController()
+                await self.setupEnactedDeterminationController()
+                await self.setupDeterminationController()
+                await self.setupInsulinController()
+                await self.setupLastBolusController()
+                await self.setupOverrideController()
+                await self.setupOverrideRunController()
+                await self.setupTempTargetController()
+                await self.setupTempTargetRunController()
+                await self.setupBatteryController()
+                await self.setupTDDController()
+
                 // The rest can be initialized concurrently
                 await withTaskGroup(of: Void.self) { group in
                     group.addTask {
-                        self.setupGlucoseArray()
-                    }
-                    group.addTask {
-                        self.setupCarbsArray()
-                    }
-                    group.addTask {
-                        self.setupFPUsArray()
-                    }
-                    group.addTask {
-                        self.setupDeterminationsArray()
-                    }
-                    group.addTask {
-                        self.setupTDDArray()
-                    }
-                    group.addTask {
-                        self.setupInsulinArray()
-                    }
-                    group.addTask {
-                        self.setupLastBolus()
-                    }
-                    group.addTask {
-                        self.setupBatteryArray()
-                    }
-                    group.addTask {
                         await self.setupBasalProfile()
                     }
                     group.addTask {
@@ -219,25 +454,12 @@ extension Home {
                         self.setupReservoir()
                     }
                     group.addTask {
-                        self.setupOverrides()
-                    }
-                    group.addTask {
-                        self.setupOverrideRunStored()
-                    }
-                    group.addTask {
-                        self.setupTempTargetsStored()
-                    }
-                    group.addTask {
-                        self.setupTempTargetsRunStored()
-                    }
-                    group.addTask {
                         self.iobService.updateIOB()
                     }
                 }
             }
         }
 
-        // These combine subscribers are only necessary due to the batch inserts of glucose/FPUs which do not trigger a ManagedObjectContext change notification
         private func registerSubscribers() {
             iobService.iobPublisher
                 .receive(on: DispatchQueue.main)
@@ -247,78 +469,16 @@ extension Home {
                 }
                 .store(in: &subscriptions)
 
-            glucoseStorage.updatePublisher
-                .receive(on: queue)
-                .sink { [weak self] _ in
-                    guard let self = self else { return }
-                    self.setupGlucoseArray()
-                }
-                .store(in: &subscriptions)
-
-            carbsStorage.updatePublisher
-                .receive(on: queue)
-                .sink { [weak self] _ in
-                    guard let self = self else { return }
-                    self.setupFPUsArray()
-                }
+            // core-data-fixes drives Core Data updates via NSFetchedResultsController delegates,
+            // so dev's glucose/carbs updatePublisher sinks and the coreDataPublisher-based
+            // registerHandlers() are obsolete here. Only the bolus-status subscription (a genuinely
+            // new feature, consumed by HomeRootView) is carried over, wired in our subscriber style.
+            provider.deviceManager.bolusTrigger
+                .receive(on: DispatchQueue.main)
+                .weakAssign(to: \.bolusStatus, on: self)
                 .store(in: &subscriptions)
         }
 
-        private func registerHandlers() {
-            coreDataPublisher?.filteredByEntityName("OrefDetermination").sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupDeterminationsArray()
-            }.store(in: &subscriptions)
-
-            coreDataPublisher?.filteredByEntityName("TDDStored").sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupTDDArray()
-            }.store(in: &subscriptions)
-
-            coreDataPublisher?.filteredByEntityName("GlucoseStored").sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupGlucoseArray()
-            }.store(in: &subscriptions)
-
-            coreDataPublisher?.filteredByEntityName("CarbEntryStored").sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupCarbsArray()
-            }.store(in: &subscriptions)
-
-            coreDataPublisher?.filteredByEntityName("PumpEventStored").sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupInsulinArray()
-                self.setupLastBolus()
-                self.displayPumpStatusHighlightMessage()
-                self.displayPumpStatusBadge()
-            }.store(in: &subscriptions)
-
-            coreDataPublisher?.filteredByEntityName("OpenAPS_Battery").sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupBatteryArray()
-            }.store(in: &subscriptions)
-
-            coreDataPublisher?.filteredByEntityName("OverrideStored").sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupOverrides()
-            }.store(in: &subscriptions)
-
-            coreDataPublisher?.filteredByEntityName("OverrideRunStored").sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupOverrideRunStored()
-            }.store(in: &subscriptions)
-
-            coreDataPublisher?.filteredByEntityName("TempTargetStored").sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupTempTargetsStored()
-            }.store(in: &subscriptions)
-
-            coreDataPublisher?.filteredByEntityName("TempTargetRunStored").sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupTempTargetsRunStored()
-            }.store(in: &subscriptions)
-        }
-
         private func registerObservers() {
             broadcaster.register(DeterminationObserver.self, observer: self)
             broadcaster.register(SettingsObserver.self, observer: self)
@@ -373,6 +533,14 @@ extension Home {
             }
             timer.resume()
 
+            Foundation.NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
+                .sink { [weak self] _ in
+                    Task { @MainActor in
+                        self?.reanchorFetchWindows()
+                    }
+                }
+                .store(in: &lifetime)
+
             fetchGlucoseManager.cgmDisplayState
                 .receive(on: DispatchQueue.main)
                 .sink { [weak self] state in self?.cgmDisplayState = state }
@@ -540,6 +708,8 @@ extension Home {
 
         func loadQuickBolusSuggestions() async {
             guard enableQuickBolus else { return }
+
+            let fetchContext = CoreDataStack.shared.newTaskContext()
             let cutoff = Calendar.current.date(byAdding: .day, value: -90, to: Date()) ?? Date()
             let predicate = NSPredicate(
                 format: "isSMB == false AND isExternal == false AND pumpEvent.timestamp >= %@",
@@ -548,14 +718,14 @@ extension Home {
             do {
                 let results: Any = try await CoreDataStack.shared.fetchEntitiesAsync(
                     ofType: BolusStored.self,
-                    onContext: pumpHistoryFetchContext,
+                    onContext: fetchContext,
                     predicate: predicate,
                     key: "pumpEvent.timestamp",
                     ascending: false,
                     batchSize: 100
                 )
 
-                let suggestions: [Decimal] = await pumpHistoryFetchContext.perform {
+                let suggestions: [Decimal] = await fetchContext.perform {
                     guard let boluses = results as? [BolusStored] else { return [] }
 
                     let now = Date()
@@ -670,7 +840,7 @@ extension Home {
 
         /// Display the eventual status message provided by the manager of the pump
         /// Only display if state is warning or critical message else return nil
-        private func displayPumpStatusHighlightMessage(_ didDeactivate: Bool = false) {
+        func displayPumpStatusHighlightMessage(_ didDeactivate: Bool = false) {
             DispatchQueue.main.async { [weak self] in
                 guard let self = self else { return }
                 if let statusHighlight = self.provider.deviceManager.pumpManager?.pumpStatusHighlight,
@@ -684,7 +854,7 @@ extension Home {
             }
         }
 
-        private func displayPumpStatusBadge(_ didDeactivate: Bool = false) {
+        func displayPumpStatusBadge(_ didDeactivate: Bool = false) {
             DispatchQueue.main.async { [weak self] in
                 guard let self = self else { return }
                 if let statusBadge = self.provider.deviceManager.pumpManager?.pumpStatusBadge,

+ 28 - 23
Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift

@@ -27,7 +27,7 @@ extension MainChartView {
                 drawSuspensions()
             }.onChange(of: state.tempBasals) {
                 calculateBasals()
-                calculateTempBasalsInBackground()
+                calculateTempBasals()
             }
             .onChange(of: state.maxBasal) {
                 calculateBasals()
@@ -137,34 +137,39 @@ extension MainChartView {
 // MARK: - Calculation
 
 extension MainChartView {
-    func calculateTempBasalsInBackground() {
-        Task {
-            let basals = await prepareTempBasals()
-            await MainActor.run {
-                preparedTempBasals = basals
-            }
-        }
-    }
-
-    func prepareTempBasals() async -> [(start: Date, end: Date, rate: Double)] {
+    @MainActor func calculateTempBasals() {
         let now = Date()
-        let tempBasals = state.tempBasals
+        let suspensionTimes = state.suspendAndResumeEvents.compactMap(\.timestamp)
 
-        return tempBasals.compactMap { temp -> (start: Date, end: Date, rate: Double)? in
-            let duration = temp.tempBasal?.duration ?? 0
-            let timestamp = temp.timestamp ?? Date()
-            let end = timestamp + duration.minutes
-            let isInsulinSuspended = state.suspendAndResumeEvents
-                .contains { $0.timestamp ?? now >= timestamp && $0.timestamp ?? now <= end }
+        // Snapshot the managed-object fields once; plain values from here on.
+        let events = state.tempBasals.map {
+            (timestamp: $0.timestamp, duration: $0.tempBasal?.duration ?? 0, rate: $0.tempBasal?.rate)
+        }
+
+        var prepared = [(start: Date, end: Date, rate: Double)]()
+        prepared.reserveCapacity(events.count)
 
-            let rate = Double(truncating: temp.tempBasal?.rate ?? Decimal.zero as NSDecimalNumber) * (isInsulinSuspended ? 0 : 1)
+        for (index, event) in events.enumerated() {
+            let timestamp = event.timestamp ?? now
+            let end = timestamp + event.duration.minutes
+            let isInsulinSuspended = suspensionTimes.contains { $0 >= timestamp && $0 <= end }
+            let rate = Double(truncating: event.rate ?? 0) * (isInsulinSuspended ? 0 : 1)
 
-            // Check if there's a subsequent temp basal to determine the end time
-            guard let nextTemp = state.tempBasals.first(where: { $0.timestamp ?? .distantPast > timestamp }) else {
-                return (timestamp, end, rate)
+            // A bar ends where the next later-starting temp basal begins,
+            // else at its own scheduled end.
+            var next = index + 1
+            while next < events.count {
+                if let nextStart = events[next].timestamp, nextStart > timestamp { break }
+                next += 1
+            }
+            if next < events.count, let nextStart = events[next].timestamp {
+                prepared.append((timestamp, nextStart, rate))
+            } else {
+                prepared.append((timestamp, end, rate))
             }
-            return (timestamp, nextTemp.timestamp ?? Date(), rate)
         }
+
+        preparedTempBasals = prepared
     }
 
     func findRegularBasalPoints(

+ 1 - 1
Trio/Sources/Modules/Home/View/Chart/MainChartView.swift

@@ -96,7 +96,7 @@ struct MainChartView: View {
                             if !mainChartHasInitialized {
                                 scroller.scrollTo("MainChart", anchor: .trailing)
                                 state.updateStartEndMarkers()
-                                calculateTempBasalsInBackground()
+                                calculateTempBasals()
                                 mainChartHasInitialized = true
                             }
                         }

+ 13 - 7
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -792,6 +792,8 @@ extension Home {
                         + String(localized: " of ", comment: "Bolus string partial message: 'x U of y U' in home view") +
                         (Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
                         + String(localized: " U", comment: "Insulin unit")
+                let bolusLabel = state
+                    .bolusStatus == .inProgress ? String(localized: "Bolusing") : String(localized: "Initiating…")
 
                 ZStack {
                     /// rectangle as background
@@ -817,7 +819,7 @@ extension Home {
                         Spacer()
 
                         VStack {
-                            Text("Bolusing")
+                            Text(bolusLabel)
                                 .font(.subheadline)
                                 .frame(maxWidth: .infinity, alignment: .leading)
                             Text(bolusString)
@@ -827,12 +829,16 @@ extension Home {
 
                         Spacer()
 
-                        Button {
-                            state.showProgressView()
-                            state.cancelBolus()
-                        } label: {
-                            Image(systemName: "xmark.app")
-                                .font(.system(size: 25))
+                        if state.bolusStatus == .inProgress {
+                            Button {
+                                state.showProgressView()
+                                state.cancelBolus()
+                            } label: {
+                                Image(systemName: "xmark.app")
+                                    .font(.system(size: 25))
+                            }
+                        } else if state.bolusStatus == .initiating {
+                            ProgressView()
                         }
                     }.padding(.horizontal, 10)
                         .padding(.trailing, 8)

+ 0 - 2
Trio/Sources/Modules/ISFEditor/ISFEditorStateModel.swift

@@ -24,8 +24,6 @@ extension ISFEditor {
         var therapyItems: [TherapySettingItem] = []
         var shouldDisplaySaving: Bool = false
 
-        let context = CoreDataStack.shared.newTaskContext()
-
         let timeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
 
         var rateValues: [Decimal] {

+ 2 - 0
Trio/Sources/Modules/LiveActivitySettings/LiveActivitySettingsStateModel.swift

@@ -9,11 +9,13 @@ extension LiveActivitySettings {
         @Published var useLiveActivity = false
         @Published var lockScreenView: LockScreenView = .simple
         @Published var smartStackView: LockScreenView = .simple
+        @Published var displayGlucoseForecasts = false
         override func subscribe() {
             units = settingsManager.settings.units
             subscribeSetting(\.useLiveActivity, on: $useLiveActivity) { useLiveActivity = $0 }
             subscribeSetting(\.lockScreenView, on: $lockScreenView) { lockScreenView = $0 }
             subscribeSetting(\.smartStackView, on: $smartStackView) { smartStackView = $0 }
+            subscribeSetting(\.displayGlucoseForecasts, on: $displayGlucoseForecasts) { displayGlucoseForecasts = $0 }
         }
     }
 }

+ 34 - 0
Trio/Sources/Modules/LiveActivitySettings/View/LiveActivitySettingsRootView.swift

@@ -9,6 +9,7 @@ extension LiveActivitySettings {
 
         @State private var shouldDisplayHintLockScreen: Bool = false
         @State private var shouldDisplayHintSmartStack: Bool = false
+        @State private var shouldDisplayHintGlucoseForecasts: Bool = false
         @State var hintDetent = PresentationDetent.large
         @State var selectedVerboseHint: AnyView?
         @State var hintLabel: String?
@@ -83,6 +84,30 @@ extension LiveActivitySettings {
                     )
 
                     if state.useLiveActivity {
+                        SettingInputSection(
+                            decimalValue: $decimalPlaceholder,
+                            booleanValue: $state.displayGlucoseForecasts,
+                            shouldDisplayHint: $shouldDisplayHintGlucoseForecasts,
+                            selectedVerboseHint: Binding(
+                                get: { selectedVerboseHint },
+                                set: {
+                                    selectedVerboseHint = $0.map { AnyView($0) }
+                                    hintLabel = String(localized: "Display Glucose Forecasts")
+                                }
+                            ),
+                            units: state.units,
+                            type: .boolean,
+                            label: String(localized: "Display Glucose Forecasts"),
+                            miniHint: String(localized: "Display Glucose Forecasts made by the Oref algorithm."),
+                            verboseHint: VStack(alignment: .leading, spacing: 10) {
+                                Text("Default: OFF").bold()
+                                Text(
+                                    "When enabled, the live activity widget on the lock screen will show glucose forecasts. The visual representation will be the same as in the Main view. To change between Cone and Lines, change the setting under Features - User Interface - Forecast Display Type."
+                                )
+                            },
+                            headerText: String(localized: "Display Glucose Forecasts")
+                        )
+
                         Section {
                             VStack {
                                 Picker(
@@ -229,6 +254,15 @@ extension LiveActivitySettings {
                     sheetTitle: String(localized: "Help", comment: "Help sheet title")
                 )
             }
+            .sheet(isPresented: $shouldDisplayHintGlucoseForecasts) {
+                SettingInputHintView(
+                    hintDetent: $hintDetent,
+                    shouldDisplayHint: $shouldDisplayHintGlucoseForecasts,
+                    hintLabel: hintLabel ?? "",
+                    hintText: selectedVerboseHint ?? AnyView(EmptyView()),
+                    sheetTitle: String(localized: "Help", comment: "Help sheet title")
+                )
+            }
             .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
             .onAppear(perform: configureView)
             .navigationTitle("Live Activity")

+ 0 - 2
Trio/Sources/Modules/NightscoutConfig/NightscoutConfigStateModel.swift

@@ -15,8 +15,6 @@ extension NightscoutConfig {
         @Injected() private var storage: FileStorage!
         @Injected() var apsManager: APSManager!
 
-        let coredataContext = CoreDataStack.shared.newTaskContext()
-
         @Published var url = ""
         @Published var secret = ""
         @Published var message = ""

+ 50 - 17
Trio/Sources/Modules/Settings/View/SettingsRootView.swift

@@ -3,6 +3,7 @@ import LoopKit
 import LoopKitUI
 import SwiftUI
 import Swinject
+import UIKit
 
 extension Settings {
     struct VersionInfo: Equatable {
@@ -34,6 +35,7 @@ extension Settings {
             isDevUpdateAvailable: false
         )
         @State private var closedLoopDisabled = true
+        @State private var showCopiedToast = false
 
         @Environment(\.colorScheme) var colorScheme
         @EnvironmentObject var appIcons: Icons
@@ -98,30 +100,49 @@ extension Settings {
             }
         }
 
+        private func copyVersionInfo(_ text: String) {
+            UIPasteboard.general.string = text
+            UINotificationFeedbackGenerator().notificationOccurred(.success)
+            withAnimation { showCopiedToast = true }
+            DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
+                withAnimation { showCopiedToast = false }
+            }
+        }
+
         var body: some View {
             List {
                 if searchText.isEmpty {
                     let buildDetails = BuildDetails.shared
 
-                    Section(
-                        header: Text("BRANCH: \(buildDetails.branchAndSha)").textCase(nil),
-                        content: {
-                            /// The current development version of the app.
-                            ///
-                            /// Follows a semantic pattern where release versions are like `0.5.0`, and
-                            /// development versions increment with a fourth component (e.g., `0.5.0.1`, `0.5.0.2`)
-                            /// after the base release. For example:
-                            /// - After release `0.5.0` → `0.5.0`
-                            /// - First dev push → `0.5.0.1`
-                            /// - Next dev push → `0.5.0.2`
-                            /// - Next release `0.6.0` → `0.6.0`
-                            /// - Next dev push → `0.6.0.1`
-                            ///
-                            /// If the dev version is unavailable, `"unknown"` is returned.
-                            let devVersion = Bundle.main.appDevVersion ?? "unknown"
+                    /// The current development version of the app.
+                    ///
+                    /// Follows a semantic pattern where release versions are like `0.5.0`, and
+                    /// development versions increment with a fourth component (e.g., `0.5.0.1`, `0.5.0.2`)
+                    /// after the base release. For example:
+                    /// - After release `0.5.0` → `0.5.0`
+                    /// - First dev push → `0.5.0.1`
+                    /// - Next dev push → `0.5.0.2`
+                    /// - Next release `0.6.0` → `0.6.0`
+                    /// - Next dev push → `0.6.0.1`
+                    ///
+                    /// If the dev version is unavailable, `"unknown"` is returned.
+                    let devVersion = Bundle.main.appDevVersion ?? "unknown"
 
-                            let buildNumber = Bundle.main.buildVersionNumber ?? String(localized: "Unknown")
+                    let buildNumber = Bundle.main.buildVersionNumber ?? String(localized: "Unknown")
 
+                    Section(
+                        header: HStack(spacing: 4) {
+                            Button {
+                                copyVersionInfo(
+                                    "Trio v\(devVersion) (\(buildNumber)) \(buildDetails.branchAndSha)"
+                                )
+                            } label: {
+                                Image(systemName: "doc.on.doc.fill")
+                            }
+                            .buttonStyle(.plain)
+                            Text("BRANCH: \(buildDetails.branchAndSha)")
+                        }.textCase(nil),
+                        content: {
                             NavigationLink(destination: SubmodulesView(buildDetails: buildDetails)) {
                                 HStack {
                                     Image(appIcons.appIcon.rawValue)
@@ -310,6 +331,18 @@ extension Settings {
                     ).listRowBackground(Color.chart)
                 }
             }
+            .overlay(alignment: .bottom) {
+                if showCopiedToast {
+                    Label("Copied", systemImage: "checkmark.circle.fill")
+                        .font(.footnote.weight(.semibold))
+                        .foregroundColor(.white)
+                        .padding(.horizontal, 16)
+                        .padding(.vertical, 10)
+                        .background(.ultraThinMaterial, in: Capsule())
+                        .padding(.bottom, 32)
+                        .transition(.move(edge: .bottom).combined(with: .opacity))
+                }
+            }
             .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
             .sheet(isPresented: $shouldDisplayHint) {
                 SettingInputHintView(

+ 2 - 0
Trio/Sources/Modules/Settings/View/Subviews/SubmodulesView.swift

@@ -28,10 +28,12 @@ struct KeyValueRow: View {
         HStack {
             Text(key)
                 .foregroundColor(.primary)
+                .textSelection(.enabled)
             Spacer()
             Text(value)
                 .foregroundColor(.secondary)
                 .multilineTextAlignment(.trailing)
+                .textSelection(.enabled)
         }
     }
 }

+ 6 - 0
Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift

@@ -871,6 +871,12 @@ extension SettingsExport {
                     name: String(localized: "Lock Screen Widget Style"),
                     value: trioSettings.lockScreenView.rawValue
                 )
+                addSetting(
+                    category: notificationsCategory,
+                    subcategory: liveActivitySubcategory,
+                    name: String(localized: "Display Glucose Forecasts"),
+                    value: trioSettings.displayGlucoseForecasts ? String(localized: "Enabled") : String(localized: "Disabled")
+                )
             }
 
             // Services

+ 6 - 0
Trio/Sources/Modules/Stat/StatStateModel+Setup/BolusStatsSetup.swift

@@ -48,6 +48,9 @@ extension Stat.StateModel {
     /// 3. Calculates total insulin for each time period
     /// 4. Returns the processed statistics as (hourly: [BolusStats], daily: [BolusStats])
     private func fetchBolusStats() async throws -> (hourly: [BolusStats], daily: [BolusStats]) {
+        let bolusTaskContext = CoreDataStack.shared.newTaskContext()
+        bolusTaskContext.name = "StatStateModel.fetchBolusStats"
+
         // Fetch PumpEventStored entries from Core Data
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: BolusStored.self,
@@ -155,6 +158,9 @@ extension Stat.StateModel {
     ///
     /// This only needs to be called once during subscribe.
     private func calculateAndCacheBolusAveragesAndTotals() async {
+        let bolusTaskContext = CoreDataStack.shared.newTaskContext()
+        bolusTaskContext.name = "StatStateModel.calculateAndCacheBolusAveragesAndTotals"
+
         let calendar = Calendar.current
 
         // Calculate averages in context

+ 10 - 1
Trio/Sources/Modules/Stat/StatStateModel+Setup/LoopChartSetup.swift

@@ -81,6 +81,9 @@ extension Stat.StateModel {
     func fetchLoopStatRecords(for interval: StatsTimeIntervalWithToday) async throws
         -> ([NSManagedObjectID], [NSManagedObjectID])
     {
+        let loopTaskContext = CoreDataStack.shared.newTaskContext()
+        loopTaskContext.name = "StatStateModel.fetchLoopStatRecords"
+
         // Calculate the date range based on selected duration
         let now = Date()
         let startDate: Date
@@ -154,6 +157,9 @@ extension Stat.StateModel {
     ) async throws
         -> [LoopStatsProcessedData]
     {
+        let loopTaskContext = CoreDataStack.shared.newTaskContext()
+        loopTaskContext.name = "StatStateModel.getLoopStats"
+
         // Calculate the date range for glucose readings
         let now = Date()
         let startDate: Date
@@ -170,7 +176,7 @@ extension Stat.StateModel {
             startDate = now.addingTimeInterval(-90.days.timeInterval)
         }
 
-        // Get glucose statistics
+        // Get glucose statistics (uses its own local context)
         let totalGlucose = try await calculateGlucoseStats(from: startDate, to: now)
 
         // Get NSManagedObject
@@ -236,6 +242,9 @@ extension Stat.StateModel {
         from startDate: Date,
         to _: Date
     ) async throws -> Int {
+        let loopTaskContext = CoreDataStack.shared.newTaskContext()
+        loopTaskContext.name = "StatStateModel.calculateGlucoseStats"
+
         // Create predicate for glucose readings
         let glucosePredicate = NSPredicate(format: "date >= %@", startDate as NSDate)
 

+ 3 - 0
Trio/Sources/Modules/Stat/StatStateModel+Setup/MealStatsSetup.swift

@@ -48,6 +48,9 @@ extension Stat.StateModel {
     /// 3. Calculates total macronutrients for each time period
     /// 4. Returns the processed statistics as (hourly: [MealStats], daily: [MealStats])
     private func fetchMealStats() async throws -> (hourly: [MealStats], daily: [MealStats]) {
+        let mealTaskContext = CoreDataStack.shared.newTaskContext()
+        mealTaskContext.name = "StatStateModel.fetchMealStats"
+
         // Fetch CarbEntryStored entries from Core Data
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,

+ 17 - 4
Trio/Sources/Modules/Stat/StatStateModel+Setup/TDDSetup.swift

@@ -34,13 +34,21 @@ extension Stat.StateModel {
     /// - Returns: A tuple containing hourly and daily TDD statistics arrays
     /// - Note: Processes both hourly statistics for the last 10 days and complete daily statistics
     private func fetchTDDStats() async throws -> (hourly: [TDDStats], daily: [TDDStats]) {
+        let tddTaskContext = CoreDataStack.shared.newTaskContext()
+        tddTaskContext.name = "StatStateModel.fetchTDDStats"
+
         // MARK: - Fetch Required Data
 
         // Fetch data for daily statistics (TDDStored for week, month, total views)
-        let tddResults = try await fetchTDDStoredRecords()
+        let tddResults = try await fetchTDDStoredRecords(on: tddTaskContext)
 
         // Fetch data for hourly statistics (BolusStored and TempBasalStored for day view)
-        let (bolusResults, tempBasalResults, suspendEvents, resumeEvents) = try await fetchHourlyInsulinRecords()
+        let (
+            bolusResults,
+            tempBasalResults,
+            suspendEvents,
+            resumeEvents
+        ) = try await fetchHourlyInsulinRecords(on: tddTaskContext)
 
         // MARK: - Process Data on Background Context
 
@@ -77,7 +85,7 @@ extension Stat.StateModel {
     /// Fetches TDDStored records from CoreData for daily statistics
     /// - Returns: The results of the fetch request containing TDDStored records
     /// - Note: Fetches records from the last 3 months for week, month, and total views
-    private func fetchTDDStoredRecords() async throws -> Any {
+    private func fetchTDDStoredRecords(on tddTaskContext: NSManagedObjectContext) async throws -> Any {
         // Create a predicate to fetch TDD records from the last 3 months
         let threeMonthsAgo = Date().addingTimeInterval(-3.months.timeInterval)
         let predicate = NSPredicate(format: "date >= %@", threeMonthsAgo as NSDate)
@@ -96,7 +104,9 @@ extension Stat.StateModel {
     /// Fetches BolusStored and TempBasalStored records from CoreData for hourly statistics
     /// - Returns: A tuple containing the results of both fetch requests
     /// - Note: Fetches records from the last 20 days for detailed hourly view
-    private func fetchHourlyInsulinRecords() async throws -> (bolus: Any, tempBasal: Any, suspendEvents: Any, resumeEvents: Any) {
+    private func fetchHourlyInsulinRecords(on tddTaskContext: NSManagedObjectContext) async throws
+        -> (bolus: Any, tempBasal: Any, suspendEvents: Any, resumeEvents: Any)
+    {
         // Calculate date range for hourly statistics (last 20 days)
         let now = Date()
         let twentyDaysAgo = Calendar.current.date(byAdding: .day, value: -20, to: now) ?? now
@@ -498,6 +508,9 @@ extension Stat.StateModel {
     /// Calculates and caches the daily averages of Total Daily Dose (TDD) insulin values
     /// - Note: This function runs asynchronously and updates the tddAveragesCache on the main actor
     private func calculateAndCacheTDDAverages() async {
+        let tddTaskContext = CoreDataStack.shared.newTaskContext()
+        tddTaskContext.name = "StatStateModel.calculateAndCacheTDDAverages"
+
         // Get calendar for date calculations
         let calendar = Calendar.current
 

+ 3 - 5
Trio/Sources/Modules/Stat/StatStateModel.swift

@@ -77,12 +77,7 @@ extension Stat {
         var selectedMealChartType: MealChartType = .totalMeals
 
         // Fetching Contexts
-        let context = CoreDataStack.shared.newTaskContext()
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-        let tddTaskContext = CoreDataStack.shared.newTaskContext()
-        let loopTaskContext = CoreDataStack.shared.newTaskContext()
-        let mealTaskContext = CoreDataStack.shared.newTaskContext()
-        let bolusTaskContext = CoreDataStack.shared.newTaskContext()
 
         override func subscribe() {
             setupGlucoseArray(for: .today)
@@ -128,6 +123,9 @@ extension Stat {
 
         private func fetchGlucose(for interval: StatsTimeIntervalWithToday) async -> [NSManagedObjectID] {
             do {
+                let context = CoreDataStack.shared.newTaskContext()
+                context.name = "StatStateModel.fetchGlucose"
+
                 let predicate: NSPredicate
 
                 switch interval {

+ 214 - 223
Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift

@@ -110,7 +110,6 @@ extension Treatments {
         var preprocessedData: [(id: UUID, forecast: Forecast, forecastValue: ForecastValue)] = []
         var predictionsForChart: Predictions?
         var simulatedDetermination: Determination?
-        @MainActor var determinationObjectIDs: [NSManagedObjectID] = []
 
         var minForecast: [Int] = []
         var maxForecast: [Int] = []
@@ -122,24 +121,73 @@ extension Treatments {
         let now = Date.now
 
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-        let glucoseFetchContext = CoreDataStack.shared.newTaskContext()
-        let determinationFetchContext = CoreDataStack.shared.newTaskContext()
-        let pumpHistoryFetchContext = CoreDataStack.shared.newTaskContext()
 
         var isActive: Bool = false
 
         var showDeterminationFailureAlert = false
         var determinationFailureMessage = ""
 
-        // Queue for handling Core Data change notifications
-        private let queue = DispatchQueue(label: "TreatmentsStateModel.queue", qos: .userInitiated)
-        private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
+        // MARK: - NSFetchedResultsControllers
+
+        //
+        // Glucose, the latest determination and the last pump bolus are driven by
+        // NSFetchedResultsControllers bound to the viewContext. They keep their `fetchedObjects`
+        // continuously in sync and notify us through their delegate's `onContentChange` closure.
+
+        @ObservationIgnored let glucoseControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var glucoseController: NSFetchedResultsController<GlucoseStored> = {
+            let request = NSFetchRequest<GlucoseStored>(entityName: "GlucoseStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \GlucoseStored.date, ascending: false)]
+            request.predicate = NSPredicate.glucose
+            request.fetchBatchSize = 50
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = glucoseControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let determinationControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var determinationController: NSFetchedResultsController<OrefDetermination> = {
+            let request = NSFetchRequest<OrefDetermination>(entityName: "OrefDetermination")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \OrefDetermination.deliverAt, ascending: false)]
+            request.predicate = NSPredicate.predicateFor30MinAgoForDetermination
+            request.fetchLimit = 1
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = determinationControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let lastBolusControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var lastBolusController: NSFetchedResultsController<PumpEventStored> = {
+            let request = NSFetchRequest<PumpEventStored>(entityName: "PumpEventStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \PumpEventStored.timestamp, ascending: false)]
+            request.predicate = NSPredicate.lastPumpBolus
+            request.fetchLimit = 1
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = lastBolusControllerDelegate
+            return controller
+        }()
+
         private var subscriptions = Set<AnyCancellable>()
 
         typealias PumpEvent = PumpEventStored.EventType
 
         var bolusProgress: Decimal?
-        var isBolusInProgress: Bool { bolusProgress != nil }
+        var bolusStatus: BolusStatus = .noBolus
         var lastPumpBolus: PumpEventStored?
 
         func unsubscribe() {
@@ -153,13 +201,6 @@ extension Treatments {
             }
 
             debug(.bolusState, "subscribe fired")
-            coreDataPublisher =
-                changedObjectsOnManagedObjectContextDidSavePublisher()
-                    .receive(on: queue)
-                    .share()
-                    .eraseToAnyPublisher()
-            registerHandlers()
-            registerSubscribers()
             setupBolusStateConcurrently()
             subscribeToBolusProgress()
         }
@@ -170,6 +211,10 @@ extension Treatments {
 
         private var hasCleanedUp = false
 
+        /// In-flight work started by this instance; cancelled in `cleanupTreatmentState()`.
+        @ObservationIgnored private var setupTask: Task<Void, Never>?
+        @ObservationIgnored private var determinationUpdateTask: Task<Void, Never>?
+
         func cleanupTreatmentState() {
             guard !hasCleanedUp else { return }
             hasCleanedUp = true
@@ -177,6 +222,16 @@ extension Treatments {
             unsubscribe()
             lifetime = Lifetime()
 
+            // Stop the FRC → recompute pipelines; a dismissed instance must not keep
+            // re-running the bolus calculator on every viewContext merge.
+            glucoseControllerDelegate.onContentChange = nil
+            determinationControllerDelegate.onContentChange = nil
+            lastBolusControllerDelegate.onContentChange = nil
+
+            // Cancel in-flight work — the setup task awaits a full oref simulation.
+            setupTask?.cancel()
+            determinationUpdateTask?.cancel()
+
             broadcaster?.unregister(DeterminationObserver.self, observer: self)
             broadcaster?.unregister(BolusFailureObserver.self, observer: self)
 
@@ -185,14 +240,13 @@ extension Treatments {
 
         private func setupBolusStateConcurrently() {
             debug(.bolusState, "Setting up bolus state concurrently...")
-            Task {
+            setupTask = Task {
+                // Load settings and observers first so the determination controller's initial
+                // population (which runs calculateInsulin) sees correct values.
                 do {
                     try await withThrowingTaskGroup(of: Void.self) { group in
                         group.addTask {
-                            self.setupGlucoseArray()
-                        }
-                        group.addTask {
-                            self.setupDeterminationsAndForecasts()
+                            await self.getAllSettingsValues()
                         }
                         group.addTask {
                             await self.setupSettings()
@@ -200,9 +254,6 @@ extension Treatments {
                         group.addTask {
                             self.registerObservers()
                         }
-                        group.addTask {
-                            self.setupLastBolus()
-                        }
 
                         // Wait for all tasks to complete
                         try await group.waitForAll()
@@ -210,6 +261,14 @@ extension Treatments {
                 } catch let error as NSError {
                     debug(.default, "Failed to setup bolus state concurrently: \(error)")
                 }
+
+                // viewContext-bound FRCs: guard and wiring share one main-actor slice.
+                await MainActor.run {
+                    guard !Task.isCancelled, !self.hasCleanedUp else { return }
+                    self.setupGlucoseController()
+                    self.setupDeterminationController()
+                    self.setupLastBolusController()
+                }
             }
         }
 
@@ -221,6 +280,11 @@ extension Treatments {
                 .receive(on: DispatchQueue.main)
                 .weakAssign(to: \.bolusProgress, on: self)
                 .store(in: &lifetime)
+
+            provider.deviceManager.bolusTrigger
+                .receive(on: DispatchQueue.main)
+                .weakAssign(to: \.bolusStatus, on: self)
+                .store(in: &lifetime)
         }
 
         func cancelBolus() {
@@ -269,20 +333,6 @@ extension Treatments {
             }
         }
 
-        private func setupDeterminationsAndForecasts() {
-            Task {
-                async let getAllSettingsDefaults: () = getAllSettingsValues()
-                async let setupDeterminations: () = setupDeterminationsArray()
-
-                await getAllSettingsDefaults
-                await setupDeterminations
-
-                // Determination has updated, so we can use this to draw the initial Forecast Chart
-                let forecastData = await mapForecastsForChart()
-                await updateForecasts(with: forecastData)
-            }
-        }
-
         private func registerObservers() {
             broadcaster.register(DeterminationObserver.self, observer: self)
             broadcaster.register(BolusFailureObserver.self, observer: self)
@@ -408,6 +458,9 @@ extension Treatments {
                 isBackdated: isBackdated
             )
 
+            // A superseded run must not overwrite the breakdown a newer run published.
+            guard !Task.isCancelled else { return apsManager.roundBolus(amount: result.insulinCalculated) }
+
             // Update state properties with calculation results on main thread
             await MainActor.run {
                 targetDifference = result.targetDifference
@@ -723,6 +776,8 @@ extension Treatments.StateModel: DeterminationObserver, BolusFailureObserver {
 
     func bolusDidFail() {
         DispatchQueue.main.async {
+            // A dismissed instance may still observe until dealloc — don't hide an unrelated modal.
+            guard self.isActive else { return }
             debug(.bolusState, "bolusDidFail fired")
             self.isAwaitingDeterminationResult = false
             if self.addButtonPressed {
@@ -732,79 +787,30 @@ extension Treatments.StateModel: DeterminationObserver, BolusFailureObserver {
     }
 }
 
-extension Treatments.StateModel {
-    private func registerHandlers() {
-        coreDataPublisher?.filteredByEntityName("OrefDetermination").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task {
-                await self.setupDeterminationsArray()
-                let forecastData = await self.mapForecastsForChart()
-                await self.updateForecasts(with: forecastData)
-            }
-        }.store(in: &subscriptions)
-
-        // Due to the Batch insert this only is used for observing Deletion of Glucose entries
-        coreDataPublisher?.filteredByEntityName("GlucoseStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            self.setupGlucoseArray()
-        }.store(in: &subscriptions)
-
-        // Refresh `lastPumpBolus` whenever a new pump event lands (mirrors HomeStateModel)
-        coreDataPublisher?.filteredByEntityName("PumpEventStored").sink { [weak self] _ in
-            self?.setupLastBolus()
-        }.store(in: &subscriptions)
-    }
-
-    private func registerSubscribers() {
-        glucoseStorage.updatePublisher
-            .receive(on: DispatchQueue.global(qos: .background))
-            .sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupGlucoseArray()
-            }
-            .store(in: &subscriptions)
-    }
-}
-
-// MARK: - Setup Glucose and Determinations
+// MARK: - Setup Glucose, Determinations and Last Bolus
 
 extension Treatments.StateModel {
-    // Glucose
-    private func setupGlucoseArray() {
-        Task {
-            do {
-                let ids = try await self.fetchGlucose()
-                let glucoseObjects: [GlucoseStored] = try await CoreDataStack.shared
-                    .getNSManagedObject(with: ids, context: viewContext)
-                await updateGlucoseArray(with: glucoseObjects)
-            } catch {
-                debug(
-                    .default,
-                    "\(DebuggingIdentifiers.failed) Error setting up glucose array: \(error)"
-                )
-            }
-        }
-    }
+    // MARK: - Glucose Controller
 
-    private func fetchGlucose() async throws -> [NSManagedObjectID] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: GlucoseStored.self,
-            onContext: glucoseFetchContext,
-            predicate: NSPredicate.glucose,
-            key: "date",
-            ascending: false
-        )
-
-        return try await glucoseFetchContext.perform {
-            guard let fetchedResults = results as? [GlucoseStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
+    @MainActor func setupGlucoseController() {
+        glucoseControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                guard let self, self.isActive else { return }
+                self.updateGlucoseFromController()
             }
+        }
 
-            return fetchedResults.map(\.objectID)
+        do {
+            try glucoseController.performFetch()
+            updateGlucoseFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform glucose fetch: \(error)")
         }
     }
 
-    @MainActor private func updateGlucoseArray(with objects: [GlucoseStored]) {
+    @MainActor private func updateGlucoseFromController() {
+        guard let objects = glucoseController.fetchedObjects else { return }
+
         // Store all objects for the forecast graph
         glucoseFromPersistence = objects
 
@@ -832,96 +838,89 @@ extension Treatments.StateModel {
         deltaBG = delta
     }
 
-    // Determinations
-    private func setupDeterminationsArray() async {
-        do {
-            let fetchedObjectIDs = try await determinationStorage.fetchLastDeterminationObjectID(
-                predicate: NSPredicate.predicateFor30MinAgoForDetermination
-            )
+    // MARK: - Determination Controller
 
-            await MainActor.run {
-                determinationObjectIDs = fetchedObjectIDs
+    @MainActor func setupDeterminationController() {
+        determinationControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                guard let self, self.isActive else { return }
+                self.updateDeterminationFromController()
+                self.scheduleInsulinAndForecastUpdate()
             }
+        }
 
-            let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared
-                .getNSManagedObject(with: determinationObjectIDs, context: viewContext)
-
-            updateDeterminationsArray(with: determinationObjects)
-        } catch let error as CoreDataError {
-            debug(.default, "Core Data error: \(error)")
+        do {
+            try determinationController.performFetch()
+            updateDeterminationFromController()
+            scheduleInsulinAndForecastUpdate()
         } catch {
-            debug(.default, "Unexpected error: \(error)")
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform determination fetch: \(error)")
         }
     }
 
-    private func mapForecastsForChart() async -> Determination? {
-        do {
-            let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared
-                .getNSManagedObject(with: determinationObjectIDs, context: determinationFetchContext)
-
-            let determination = await determinationFetchContext.perform {
-                let determinationObject = determinationObjects.first
-
-                let forecastsSet = determinationObject?.forecasts ?? []
-                let predictions = Predictions(
-                    iob: forecastsSet.extractValues(for: "iob"),
-                    zt: forecastsSet.extractValues(for: "zt"),
-                    cob: forecastsSet.extractValues(for: "cob"),
-                    uam: forecastsSet.extractValues(for: "uam")
-                )
-
-                return Determination(
-                    id: UUID(),
-                    reason: "",
-                    units: 0,
-                    insulinReq: 0,
-                    sensitivityRatio: 0,
-                    rate: 0,
-                    duration: 0,
-                    iob: 0,
-                    cob: 0,
-                    predictions: predictions.isEmpty ? nil : predictions,
-                    carbsReq: 0,
-                    temp: nil,
-                    reservoir: 0,
-                    carbRatio: 0,
-                    received: false
-                )
-            }
+    /// Recomputes bolus recommendation and forecast; each new determination cancels the
+    /// previous in-flight run so a superseded run cannot publish stale results.
+    @MainActor private func scheduleInsulinAndForecastUpdate() {
+        determinationUpdateTask?.cancel()
+        determinationUpdateTask = Task { @MainActor in
+            let insulinCalculated = await self.calculateInsulin()
+            guard !Task.isCancelled else { return }
+            self.insulinCalculated = insulinCalculated
+            let forecastData = self.mapForecastsFromController()
+            await self.updateForecasts(with: forecastData)
+        }
+    }
 
-            guard !determinationObjects.isEmpty else {
-                return nil
-            }
+    @MainActor private func updateDeterminationFromController() {
+        guard let objects = determinationController.fetchedObjects,
+              let mostRecentDetermination = objects.first else { return }
+
+        determination = objects
+
+        // setup vars for bolus calculation
+        insulinRequired = (mostRecentDetermination.insulinReq ?? 0) as Decimal
+        evBG = (mostRecentDetermination.eventualBG ?? 0) as Decimal
+        minPredBG = (mostRecentDetermination.minPredBGFromReason ?? 0) as Decimal
+        lastLoopDate = apsManager.lastLoopDate as Date?
+        insulin = (mostRecentDetermination.insulinForManualBolus ?? 0) as Decimal
+        target = (mostRecentDetermination.currentTarget ?? currentBGTarget as NSDecimalNumber) as Decimal
+        isf = (mostRecentDetermination.insulinSensitivity ?? currentISF as NSDecimalNumber) as Decimal
+        cob = mostRecentDetermination.cob as Int16
+        iob = (mostRecentDetermination.iob ?? 0) as Decimal
+        basal = (mostRecentDetermination.tempBasal ?? 0) as Decimal
+        carbRatio = (mostRecentDetermination.carbRatio ?? currentCarbRatio as NSDecimalNumber) as Decimal
+    }
 
-            return determination
-        } catch {
-            debug(
-                .default,
-                "\(DebuggingIdentifiers.failed) Error mapping forecasts for chart: \(error)"
-            )
+    @MainActor private func mapForecastsFromController() -> Determination? {
+        guard let determinationObject = determinationController.fetchedObjects?.first else {
             return nil
         }
-    }
 
-    private func updateDeterminationsArray(with objects: [OrefDetermination]) {
-        Task { @MainActor in
-            guard let mostRecentDetermination = objects.first else { return }
-            determination = objects
-
-            // setup vars for bolus calculation
-            insulinRequired = (mostRecentDetermination.insulinReq ?? 0) as Decimal
-            evBG = (mostRecentDetermination.eventualBG ?? 0) as Decimal
-            minPredBG = (mostRecentDetermination.minPredBGFromReason ?? 0) as Decimal
-            lastLoopDate = apsManager.lastLoopDate as Date?
-            insulin = (mostRecentDetermination.insulinForManualBolus ?? 0) as Decimal
-            target = (mostRecentDetermination.currentTarget ?? currentBGTarget as NSDecimalNumber) as Decimal
-            isf = (mostRecentDetermination.insulinSensitivity ?? currentISF as NSDecimalNumber) as Decimal
-            cob = mostRecentDetermination.cob as Int16
-            iob = (mostRecentDetermination.iob ?? 0) as Decimal
-            basal = (mostRecentDetermination.tempBasal ?? 0) as Decimal
-            carbRatio = (mostRecentDetermination.carbRatio ?? currentCarbRatio as NSDecimalNumber) as Decimal
-            insulinCalculated = await calculateInsulin()
-        }
+        let forecastsSet = determinationObject.forecasts ?? []
+        let predictions = Predictions(
+            iob: forecastsSet.extractValues(for: "iob"),
+            zt: forecastsSet.extractValues(for: "zt"),
+            cob: forecastsSet.extractValues(for: "cob"),
+            uam: forecastsSet.extractValues(for: "uam")
+        )
+
+        return Determination(
+            id: UUID(),
+            reason: "",
+            units: 0,
+            insulinReq: 0,
+            sensitivityRatio: 0,
+            rate: 0,
+            duration: 0,
+            iob: 0,
+            cob: 0,
+            predictions: predictions.isEmpty ? nil : predictions,
+            carbsReq: 0,
+            temp: nil,
+            reservoir: 0,
+            carbRatio: 0,
+            received: false
+        )
     }
 }
 
@@ -937,7 +936,7 @@ extension Treatments.StateModel {
             simulatedDetermination = forecastData
             debugPrint("\(DebuggingIdentifiers.failed) minPredBG: \(minPredBG)")
         } else {
-            simulatedDetermination = await Task { [self] in
+            let simulated = await Task { [self] in
                 debug(.bolusState, "calling simulateDetermineBasal to get forecast data")
                 return await apsManager.simulateDetermineBasal(
                     simulatedCarbsAmount: carbs,
@@ -946,8 +945,12 @@ extension Treatments.StateModel {
                 )
             }.value
 
+            // Stale minPredBG/cob from a superseded run would feed the next bolus calculation.
+            guard !Task.isCancelled else { return }
+            simulatedDetermination = simulated
+
             // Update evBG and minPredBG from simulated determination
-            if let simDetermination = simulatedDetermination {
+            if let simDetermination = simulated {
                 evBG = Decimal(simDetermination.eventualBG ?? 0)
                 minPredBG = simDetermination.minPredBGFromReason ?? 0
                 debugPrint("\(DebuggingIdentifiers.inProgress) minPredBG: \(minPredBG)")
@@ -986,18 +989,13 @@ extension Treatments.StateModel {
             }
         }.value
 
-        minForecast = await minForecastResult
-        maxForecast = await maxForecastResult
-    }
-}
+        let minResult = await minForecastResult
+        let maxResult = await maxForecastResult
 
-private extension Set where Element == Forecast {
-    func extractValues(for type: String) -> [Int]? {
-        let values = first { $0.type == type }?
-            .forecastValues?
-            .sorted { $0.index < $1.index }
-            .compactMap { Int($0.value) }
-        return values?.isEmpty ?? true ? nil : values
+        guard !Task.isCancelled else { return }
+
+        minForecast = minResult
+        maxForecast = maxResult
     }
 }
 
@@ -1010,43 +1008,36 @@ private extension Predictions {
 // MARK: - Last Pump Bolus
 
 extension Treatments.StateModel {
-    /// Mirrors `HomeStateModel.setupLastBolus` so the in-progress visualizer can show the
+    /// Mirrors `HomeStateModel`'s last-bolus controller so the in-progress visualizer can show the
     /// running pump-bolus's amount as the denominator (not the user's pending entry).
     /// Filters out external boluses via `NSPredicate.lastPumpBolus`.
-    func setupLastBolus() {
-        Task {
-            do {
-                guard let id = try await fetchLastBolus() else { return }
-                await updateLastBolus(with: id)
-            } catch {
-                debug(.default, "\(DebuggingIdentifiers.failed) Error setting up last bolus: \(error)")
+    @MainActor func setupLastBolusController() {
+        lastBolusControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                guard let self, self.isActive else { return }
+                self.updateLastBolusFromController()
             }
         }
-    }
 
-    private func fetchLastBolus() async throws -> NSManagedObjectID? {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: PumpEventStored.self,
-            onContext: pumpHistoryFetchContext,
-            predicate: NSPredicate.lastPumpBolus,
-            key: "timestamp",
-            ascending: false,
-            fetchLimit: 1
-        )
-
-        return try await pumpHistoryFetchContext.perform {
-            guard let fetched = results as? [PumpEventStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
-            return fetched.map(\.objectID).first
-        }
-    }
-
-    @MainActor private func updateLastBolus(with id: NSManagedObjectID) {
         do {
-            lastPumpBolus = try viewContext.existingObject(with: id) as? PumpEventStored
+            try lastBolusController.performFetch()
+            updateLastBolusFromController()
         } catch {
-            debug(.default, "\(DebuggingIdentifiers.failed) updateLastBolus: \(error)")
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform last bolus fetch: \(error)")
         }
     }
+
+    @MainActor private func updateLastBolusFromController() {
+        lastPumpBolus = lastBolusController.fetchedObjects?.first
+    }
+}
+
+private extension Set where Element == Forecast {
+    /// Extracts the sorted forecast values for a given prediction type (iob/zt/cob/uam).
+    func extractValues(for type: String) -> [Int]? {
+        let values = first { $0.type == type }?
+            .forecastValuesArray
+            .map { Int($0.value) }
+        return (values?.isEmpty ?? true) ? nil : values
+    }
 }

+ 14 - 9
Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift

@@ -492,7 +492,7 @@ extension Treatments {
         }
 
         var treatmentButton: some View {
-            let shouldDisplayBolusProgress = state.isBolusInProgress && state.amount > 0 &&
+            let shouldDisplayBolusProgress = state.bolusStatus != .noBolus && state.amount > 0 &&
                 !state.externalInsulin && (state.carbs == 0 || state.fat == 0 || state.protein == 0)
 
             var treatmentButtonBackground = Color(.systemBlue)
@@ -568,6 +568,7 @@ extension Treatments {
                     + (Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
                     + String(localized: " U", comment: "Insulin unit")
             }()
+            let bolusLabel = state.bolusStatus == .inProgress ? String(localized: "Bolusing") : String(localized: "Initiating…")
 
             ZStack {
                 // background card
@@ -593,7 +594,7 @@ extension Treatments {
                     Spacer()
 
                     VStack {
-                        Text("Bolusing")
+                        Text(bolusLabel)
                             .font(.subheadline)
                             .frame(maxWidth: .infinity, alignment: .leading)
                         Text(bolusString)
@@ -604,12 +605,16 @@ extension Treatments {
 
                     Spacer()
 
-                    Button { state.cancelBolus() } label: {
-                        Image(systemName: "xmark.app")
-                            .font(.system(size: 25))
-                    }.tint(Color.tabBar)
-                        .buttonStyle(.borderless)
-                        .accessibilityLabel("Cancel bolus")
+                    if state.bolusStatus == .inProgress {
+                        Button { state.cancelBolus() } label: {
+                            Image(systemName: "xmark.app")
+                                .font(.system(size: 25))
+                        }.tint(Color.tabBar)
+                            .buttonStyle(.borderless)
+                            .accessibilityLabel("Cancel bolus")
+                    } else if state.bolusStatus == .initiating {
+                        ProgressView()
+                    }
                 }
                 .padding(.horizontal, 10)
                 .padding(.trailing, 8)
@@ -690,7 +695,7 @@ extension Treatments {
 
         private var disableTaskButton: Bool {
             (
-                state.isBolusInProgress && state
+                state.bolusStatus != .noBolus && state
                     .amount > 0 && !state.externalInsulin && (state.carbs == 0 || state.fat == 0 || state.protein == 0)
             ) || state
                 .addButtonPressed || limitExceeded

+ 4 - 3
Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift

@@ -27,7 +27,6 @@ final class GlucoseAlertCoordinator: Injectable {
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var fetchGlucoseManager: FetchGlucoseManager!
 
-    private let coreDataContext = CoreDataStack.shared.newTaskContext()
     private let evaluationQueue = DispatchQueue(label: "GlucoseAlertCoordinator.queue")
     private var firingAlertIDs: Set<UUID> = []
     private var subscriptions = Set<AnyCancellable>()
@@ -403,16 +402,18 @@ final class GlucoseAlertCoordinator: Injectable {
     private func fetchLatestReadingMgDL() async -> Decimal? {
         let cutoff = Date().addingTimeInterval(-Self.readingFreshnessWindow)
         let predicate = NSPredicate(format: "date >= %@", cutoff as NSDate)
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "GlucoseAlertCoordinator.fetchLatestReading"
         do {
             let results = try await CoreDataStack.shared.fetchEntitiesAsync(
                 ofType: GlucoseStored.self,
-                onContext: coreDataContext,
+                onContext: context,
                 predicate: predicate,
                 key: "date",
                 ascending: false,
                 fetchLimit: 1
             )
-            return await coreDataContext.perform {
+            return await context.perform {
                 guard let latest = (results as? [GlucoseStored])?.first else { return nil }
                 return Decimal(latest.glucose)
             }

+ 8 - 5
Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift

@@ -21,9 +21,6 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
     @Injected() private var fileStorage: FileStorage!
     @Injected() private var determinationStorage: DeterminationStorage!
 
-    let glucoseFetchContext = CoreDataStack.shared.newTaskContext()
-    let determinationFetchContext = CoreDataStack.shared.newTaskContext()
-
     init(resolver: Resolver) {
         injectServices(resolver)
     }
@@ -191,15 +188,17 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
     /// Fetches recent glucose readings from CoreData
     /// - Returns: Array of NSManagedObjectIDs for glucose readings
     private func fetchGlucose() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchGlucose"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: glucoseFetchContext,
+            onContext: context,
             predicate: NSPredicate.glucose,
             key: "date",
             ascending: false
         )
 
-        return try await glucoseFetchContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -312,6 +311,8 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
             let maxCOB = preferences.maxCOB
 
             // Fetch glucose data
+            let glucoseFetchContext = CoreDataStack.shared.newTaskContext()
+            glucoseFetchContext.name = "handleBolusCalculation.glucose"
             let glucoseIds = try await fetchGlucose()
             let glucoseObjects: [GlucoseStored] = try await CoreDataStack.shared.getNSManagedObject(
                 with: glucoseIds,
@@ -322,6 +323,8 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
             }
 
             // Fetch determination data
+            let determinationFetchContext = CoreDataStack.shared.newTaskContext()
+            determinationFetchContext.name = "handleBolusCalculation.determination"
             let determinationIds = try await determinationStorage.fetchLastDeterminationObjectID(
                 predicate: NSPredicate.predicateFor30MinAgoForDetermination
             )

+ 2 - 3
Trio/Sources/Services/BolusSafety/BolusSafetyValidator.swift

@@ -64,10 +64,7 @@ final class BaseBolusSafetyValidator: BolusSafetyValidator, Injectable {
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var iobService: IOBService!
 
-    private let fetchContext: NSManagedObjectContext
-
     init(resolver: Resolver) {
-        fetchContext = CoreDataStack.shared.newTaskContext()
         injectServices(resolver)
     }
 
@@ -84,6 +81,8 @@ final class BaseBolusSafetyValidator: BolusSafetyValidator, Injectable {
     }
 
     func fetchTotalRecentBolusAmount(since date: Date) async throws -> Decimal {
+        let fetchContext = CoreDataStack.shared.newTaskContext()
+        fetchContext.name = "fetchTotalRecentBolusAmount"
         let predicate = NSPredicate(
             format: "type == %@ AND timestamp > %@",
             PumpEventStored.EventType.bolus.rawValue,

+ 9 - 6
Trio/Sources/Services/Calendar/CalendarManager.swift

@@ -65,7 +65,7 @@ final class BaseCalendarManager: CalendarManager, Injectable {
         setupCurrentCalendar()
 
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()
@@ -77,7 +77,6 @@ final class BaseCalendarManager: CalendarManager, Injectable {
         registerSubscribers()
     }
 
-    let backgroundContext = CoreDataStack.shared.newTaskContext()
     let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
     private func setupCurrentCalendar() {
@@ -177,9 +176,11 @@ final class BaseCalendarManager: CalendarManager, Injectable {
     }
 
     private func getLastDetermination() async throws -> NSManagedObjectID? {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "getLastDetermination"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.predicateFor30MinAgoForDetermination,
             key: "timestamp",
             ascending: false,
@@ -187,7 +188,7 @@ final class BaseCalendarManager: CalendarManager, Injectable {
             propertiesToFetch: ["timestamp", "cob", "iob", "objectID"]
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [[String: Any]] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -197,16 +198,18 @@ final class BaseCalendarManager: CalendarManager, Injectable {
     }
 
     private func fetchGlucose() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchGlucose"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.predicateFor30MinAgo,
             key: "date",
             ascending: false,
             propertiesToFetch: ["objectID", "glucose"]
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [[String: Any]] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }

+ 9 - 6
Trio/Sources/Services/ContactImage/ContactImageManager.swift

@@ -31,7 +31,6 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
     private(set) var state = ContactImageState()
 
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-    private let backgroundContext = CoreDataStack.shared.newTaskContext()
 
     // Queue for handling Core Data change notifications
     private let queue = DispatchQueue(label: "BaseContactImageManager.queue", qos: .background)
@@ -56,7 +55,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
         injectServices(resolver)
         units = settingsManager.settings.units
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()
@@ -101,16 +100,18 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
     // MARK: - Core Data Fetches
 
     private func fetchlastDetermination() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchlastDetermination"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate(format: "deliverAt >= %@", Date.halfHourAgo as NSDate), // fetches enacted and suggested
             key: "deliverAt",
             ascending: false,
             fetchLimit: 1
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [OrefDetermination] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -120,16 +121,18 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
     }
 
     private func fetchGlucose() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchGlucose"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.predicateFor20MinAgo,
             key: "date",
             ascending: false,
             fetchLimit: 3 /// We only need 1-3 values, depending on whether the user wants to show delta or not
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let glucoseResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }

+ 101 - 60
Trio/Sources/Services/HealthKit/HealthKitManager.swift

@@ -55,12 +55,65 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
     @Injected() private var pumpHistoryStorage: PumpHistoryStorage!
     @Injected() private var deviceDataManager: DeviceDataManager!
 
-    private var backgroundContext = CoreDataStack.shared.newTaskContext()
-
-    // Queue for handling Core Data change notifications
-    private let queue = DispatchQueue(label: "BaseHealthKitManager.queue", qos: .background)
-    private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
-    private var subscriptions = Set<AnyCancellable>()
+    let viewContext = CoreDataStack.shared.persistentContainer.viewContext
+
+    // MARK: - Upload triggers
+
+    //
+    // Each upload pipeline is driven by an NSFetchedResultsController whose predicate is the
+    // "not yet uploaded to Health" set. The controller fires whenever un-uploaded items appear
+    // (or drop out after a successful upload), which we use to (re-)trigger the matching upload.
+    // Bound to the viewContext, it also picks up batch-inserted glucose via the persistent history
+    // merge in CoreDataStack — replacing the previous changedObjects publisher plus the separate
+    // glucoseStorage.updatePublisher fallback.
+
+    let glucoseUploadControllerDelegate = FetchedResultsControllerDelegate()
+    private lazy var glucoseUploadController: NSFetchedResultsController<GlucoseStored> = {
+        let request = NSFetchRequest<GlucoseStored>(entityName: "GlucoseStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \GlucoseStored.date, ascending: true)]
+        request.predicate = NSPredicate.glucoseNotYetUploadedToHealth
+        request.fetchBatchSize = 50
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = glucoseUploadControllerDelegate
+        return controller
+    }()
+
+    let carbsUploadControllerDelegate = FetchedResultsControllerDelegate()
+    private lazy var carbsUploadController: NSFetchedResultsController<CarbEntryStored> = {
+        let request = NSFetchRequest<CarbEntryStored>(entityName: "CarbEntryStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \CarbEntryStored.date, ascending: true)]
+        request.predicate = NSPredicate.carbsNotYetUploadedToHealth
+        request.fetchBatchSize = 50
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = carbsUploadControllerDelegate
+        return controller
+    }()
+
+    let insulinUploadControllerDelegate = FetchedResultsControllerDelegate()
+    private lazy var insulinUploadController: NSFetchedResultsController<PumpEventStored> = {
+        let request = NSFetchRequest<PumpEventStored>(entityName: "PumpEventStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \PumpEventStored.timestamp, ascending: true)]
+        request.predicate = NSPredicate.pumpEventsNotYetUploadedToHealth
+        request.fetchBatchSize = 50
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = insulinUploadControllerDelegate
+        return controller
+    }()
 
     var isAvailableOnCurrentDevice: Bool {
         HKHealthStore.isHealthDataAvailable()
@@ -69,23 +122,7 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
     init(resolver: Resolver) {
         injectServices(resolver)
 
-        coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
-                .receive(on: queue)
-                .share()
-                .eraseToAnyPublisher()
-
-        glucoseStorage.updatePublisher
-            .receive(on: DispatchQueue.global(qos: .background))
-            .sink { [weak self] _ in
-                guard let self = self else { return }
-                Task {
-                    await self.uploadGlucose()
-                }
-            }
-            .store(in: &subscriptions)
-
-        registerHandlers()
+        registerUploadControllers()
 
         guard isAvailableOnCurrentDevice,
               AppleHealthConfig.healthBGObject != nil else { return }
@@ -93,31 +130,27 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
         debug(.service, "HealthKitManager did create")
     }
 
-    private func registerHandlers() {
-        coreDataPublisher?.filteredByEntityName("PumpEventStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task { [weak self] in
-                guard let self = self else { return }
-                await self.uploadInsulin()
-            }
-        }.store(in: &subscriptions)
+    private func registerUploadControllers() {
+        glucoseUploadControllerDelegate.onContentChange = { [weak self] in
+            Task { await self?.uploadGlucose() }
+        }
+        carbsUploadControllerDelegate.onContentChange = { [weak self] in
+            Task { await self?.uploadCarbs() }
+        }
+        insulinUploadControllerDelegate.onContentChange = { [weak self] in
+            Task { await self?.uploadInsulin() }
+        }
 
-        coreDataPublisher?.filteredByEntityName("CarbEntryStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task { [weak self] in
-                guard let self = self else { return }
-                await self.uploadCarbs()
-            }
-        }.store(in: &subscriptions)
-
-        // This works only for manual Glucose
-        coreDataPublisher?.filteredByEntityName("GlucoseStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task { [weak self] in
-                guard let self = self else { return }
-                await self.uploadGlucose()
+        // performFetch must run on the viewContext's queue (main).
+        Task { @MainActor in
+            do {
+                try self.glucoseUploadController.performFetch()
+                try self.carbsUploadController.performFetch()
+                try self.insulinUploadController.performFetch()
+            } catch {
+                debug(.service, "\(DebuggingIdentifiers.failed) Failed to set up HealthKit upload controllers: \(error)")
             }
-        }.store(in: &subscriptions)
+        }
     }
 
     func checkWriteToHealthPermissions(objectTypeToHealthStore: HKObjectType) -> Bool {
@@ -214,19 +247,21 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
     }
 
     private func updateGlucoseAsUploaded(_ glucose: [BloodGlucose]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateGlucoseAsUploaded"
+        await context.perform {
             let ids = glucose.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToHealth = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToHealth: \(error.userInfo)"
@@ -335,19 +370,21 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
     }
 
     private func updateCarbsAsUploaded(_ carbs: [CarbsEntry]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateCarbsAsUploaded"
+        await context.perform {
             let ids = carbs.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<CarbEntryStored> = CarbEntryStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToHealth = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToHealth: \(error.userInfo)"
@@ -377,9 +414,11 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
               insulinEvents.isNotEmpty else { return }
 
         do {
+            let context = CoreDataStack.shared.newTaskContext()
+            context.name = "uploadInsulin"
             let fetchedInsulinEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
                 ofType: PumpEventStored.self,
-                onContext: backgroundContext,
+                onContext: context,
                 predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [
                     NSPredicate.pumpHistoryLast24h,
                     NSPredicate(format: "tempBasal != nil")
@@ -391,7 +430,7 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
 
             var insulinSamples: [HKQuantitySample] = []
 
-            try await backgroundContext.perform {
+            try await context.perform {
                 guard let existingTempBasalEntries = fetchedInsulinEntries as? [PumpEventStored] else {
                     throw CoreDataError.fetchError(function: #function, file: #file)
                 }
@@ -582,19 +621,21 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
     }
 
     private func updateInsulinAsUploaded(_ insulin: [PumpHistoryEvent]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateInsulinAsUploaded"
+        await context.perform {
             let ids = insulin.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToHealth = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToHealth: \(error.userInfo)"

+ 3 - 2
Trio/Sources/Services/IOB/IOBService.swift

@@ -34,12 +34,11 @@ final class BaseIOBService: IOBService, Injectable {
     private var subscriptions = Set<AnyCancellable>()
     private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
     private let queue = DispatchQueue(label: "BaseIOBService.queue", qos: .background)
-    private let context = CoreDataStack.shared.newTaskContext()
 
     init(resolver: Resolver) {
         injectServices(resolver)
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()
@@ -64,6 +63,8 @@ final class BaseIOBService: IOBService, Injectable {
     private func fetchLatestDeterminationIOB() -> (iob: Decimal?, date: Date?) {
         var iob: Decimal?
         var date: Date?
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchLatestDeterminationIOB"
         context.performAndWait {
             let request = OrefDetermination.fetchRequest() as NSFetchRequest<OrefDetermination>
             request.sortDescriptors = [NSSortDescriptor(key: "deliverAt", ascending: false)]

+ 47 - 5
Trio/Sources/Services/LiveActivity/Data/DataManager.swift

@@ -6,6 +6,8 @@ import Foundation
 @available(iOS 16.2, *)
 extension LiveActivityManager {
     func fetchAndMapGlucose() async throws -> [GlucoseData] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchAndMapGlucose"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: context,
@@ -27,6 +29,8 @@ extension LiveActivityManager {
 
     // TODO: extract logic or at least rename function appropiately
     func fetchAndMapDetermination() async throws -> DeterminationData? {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchAndMapDetermination"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
             onContext: context,
@@ -34,7 +38,7 @@ extension LiveActivityManager {
             key: "deliverAt",
             ascending: false,
             fetchLimit: 1,
-            propertiesToFetch: ["cob", "currentTarget", "deliverAt"]
+            relationshipKeyPathsForPrefetching: ["forecasts", "forecasts.forecastValues"]
         )
 
         let tddResults = try await CoreDataStack.shared.fetchEntitiesAsync(
@@ -48,7 +52,9 @@ extension LiveActivityManager {
         )
 
         return try await context.perform {
-            guard let determinationResults = results as? [[String: Any]], let tddResults = tddResults as? [[String: Any]] else {
+            guard let determinationResults = results as? [OrefDetermination],
+                  let tddResults = tddResults as? [[String: Any]]
+            else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
 
@@ -58,11 +64,44 @@ extension LiveActivityManager {
 
             let tddValue = (tddResults.first?["total"] as? NSDecimalNumber)?.decimalValue ?? 0
 
+            // Compute cone bounds and per-type lines from forecast relationships (cap at 24 values = 2h)
+            var allForecastValues = [[Int]]()
+            var forecastLines = [(type: String, values: [Int])]()
+
+            if let forecasts = determination.forecasts {
+                let hasCarbs = forecasts.contains(where: {
+                    ($0.type == "cob" || $0.type == "uam") && !$0.forecastValuesArray.isEmpty
+                })
+                for forecast in forecasts.sorted(by: { ($0.type ?? "") < ($1.type ?? "") }) {
+                    let values = forecast.forecastValuesArray.prefix(24).map { Int($0.value) }
+                    guard !values.isEmpty else { continue }
+                    // iob is hidden when cob or uam are active (matches phone app behavior)
+                    if forecast.type == "iob", hasCarbs { continue }
+                    allForecastValues.append(Array(values))
+                    if let type = forecast.type {
+                        forecastLines.append((type: type, values: Array(values)))
+                    }
+                }
+            }
+
+            let minCount = allForecastValues.map(\.count).min() ?? 0
+            var minForecast = [Int]()
+            var maxForecast = [Int]()
+
+            for index in 0 ..< minCount {
+                let col = allForecastValues.compactMap { $0.indices.contains(index) ? $0[index] : nil }
+                minForecast.append(col.min() ?? 0)
+                maxForecast.append(col.max() ?? 0)
+            }
+
             return DeterminationData(
-                cob: (determination["cob"] as? Int) ?? 0,
+                cob: Int(determination.cob),
                 tdd: tddValue,
-                target: (determination["currentTarget"] as? NSDecimalNumber)?.decimalValue ?? 0,
-                date: determination["deliverAt"] as? Date ?? nil
+                target: determination.currentTarget?.decimalValue ?? 0,
+                date: determination.deliverAt,
+                minForecast: minForecast,
+                maxForecast: maxForecast,
+                forecastLines: forecastLines
             )
         }
     }
@@ -108,6 +147,9 @@ extension LiveActivityManager {
         propertiesToFetch: [String],
         map: @escaping ([String: Any]) -> Output
     ) async throws -> Output? {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchAndMapLatest"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: type,
             onContext: context,

+ 3 - 0
Trio/Sources/Services/LiveActivity/Data/DeterminationData.swift

@@ -5,4 +5,7 @@ struct DeterminationData {
     let tdd: Decimal
     let target: Decimal
     let date: Date?
+    let minForecast: [Int]
+    let maxForecast: [Int]
+    let forecastLines: [(type: String, values: [Int])]
 }

+ 9 - 0
Trio/Sources/Services/LiveActivity/LiveActitiyAttributes.swift

@@ -49,6 +49,10 @@ struct LiveActivityAttributes: ActivityAttributes {
         let tempTargetDuration: Decimal
         let tempTargetTarget: Decimal
         let widgetItems: [LiveActivityItem]
+        let minForecast: [Int]
+        let maxForecast: [Int]
+        let forecastLines: [ForecastLine]
+        let forecastDisplayType: String
     }
 
     struct ChartItem: Codable, Hashable {
@@ -56,5 +60,10 @@ struct LiveActivityAttributes: ActivityAttributes {
         let date: Date
     }
 
+    struct ForecastLine: Codable, Hashable {
+        let type: String
+        let values: [Int]
+    }
+
     let startDate: Date
 }

+ 10 - 1
Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift

@@ -118,7 +118,16 @@ extension LiveActivityAttributes.ContentState {
             tempTargetDate: tempTarget?.date ?? Date(),
             tempTargetDuration: tempTarget?.duration ?? 0,
             tempTargetTarget: tempTarget?.target ?? 0,
-            widgetItems: widgetItems ?? [] // set empty array here to silence compiler; this can never be nil
+            widgetItems: widgetItems ?? [], // set empty array here to silence compiler; this can never be nil
+            minForecast: settings.displayGlucoseForecasts && settings.forecastDisplayType == .cone
+                ? (determination?.minForecast ?? []) : [],
+            maxForecast: settings.displayGlucoseForecasts && settings.forecastDisplayType == .cone
+                ? (determination?.maxForecast ?? []) : [],
+            forecastLines: settings.displayGlucoseForecasts && settings.forecastDisplayType == .lines
+                ? (determination?.forecastLines ?? [])
+                .map { LiveActivityAttributes.ForecastLine(type: $0.type, values: $0.values) }
+                : [],
+            forecastDisplayType: settings.forecastDisplayType.rawValue
         )
 
         self.init(

+ 6 - 5
Trio/Sources/Services/LiveActivity/LiveActivityManager.swift

@@ -71,9 +71,6 @@ final class LiveActivityData: ObservableObject {
 
     private var data = LiveActivityData()
 
-    /// A Core Data task context.
-    let context = CoreDataStack.shared.newTaskContext()
-
     /// A dispatch queue for handling Core Data change notifications.
     private let queue = DispatchQueue(label: "LiveActivityBridge.queue", qos: .userInitiated)
     private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
@@ -84,7 +81,7 @@ final class LiveActivityData: ObservableObject {
     /// - Parameter resolver: The dependency injection resolver.
     init(resolver: Resolver) {
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()
@@ -322,7 +319,11 @@ final class LiveActivityData: ObservableObject {
                                 tempTargetDate: Date.now,
                                 tempTargetDuration: 0,
                                 tempTargetTarget: 0,
-                                widgetItems: []
+                                widgetItems: [],
+                                minForecast: [],
+                                maxForecast: [],
+                                forecastLines: [],
+                                forecastDisplayType: ForecastDisplayType.cone.rawValue
                             ),
                             isInitialState: true
                         ),

+ 45 - 56
Trio/Sources/Services/Network/Nightscout/BaseNightscoutManager+Subscribers.swift

@@ -5,12 +5,10 @@ import Foundation
 extension BaseNightscoutManager {
     /// Call once from init. Hooks up:
     /// 1) external upload requests (NotificationCenter)
-    /// 2) Core Data change triggers → requests per upload pipeline
-    /// 3) Glucose storage updates → request glucose pipeline
+    /// 2) Core Data "not yet uploaded" triggers → requests per upload pipeline
     func wireSubscribers() {
         wireExternalUploadRequests()
-        wireCoreDataSubscribers()
-        wireGlucoseStorageSubscriber()
+        wireUploadControllers()
     }
 
     /// Listens for `.nightscoutUploadRequested`, converts userInfo pipelines to enums,
@@ -31,59 +29,50 @@ extension BaseNightscoutManager {
             .store(in: &subscriptions)
     }
 
-    /// Maps Core Data entity changes into upload pipeline requests. We rely on
-    /// per-pipeline throttle so rapid changes don’t spam Nightscout.
-    func wireCoreDataSubscribers() {
-        coreDataPublisher?
-            .filteredByEntityName("OrefDetermination")
-            .sink { [weak self] _ in self?.requestUpload(.deviceStatus) }
-            .store(in: &subscriptions)
-
-        coreDataPublisher?
-            .filteredByEntityName("OverrideStored")
-            .sink { [weak self] _ in self?.requestUpload(.overrides) }
-            .store(in: &subscriptions)
-
-        coreDataPublisher?
-            .filteredByEntityName("OverrideRunStored")
-            .sink { [weak self] _ in self?.requestUpload(.overrides) }
-            .store(in: &subscriptions)
-
-        coreDataPublisher?
-            .filteredByEntityName("TempTargetStored")
-            .sink { [weak self] _ in self?.requestUpload(.tempTargets) }
-            .store(in: &subscriptions)
-
-        coreDataPublisher?
-            .filteredByEntityName("TempTargetRunStored")
-            .sink { [weak self] _ in self?.requestUpload(.tempTargets) }
-            .store(in: &subscriptions)
+    /// Maps Core Data "not yet uploaded to Nightscout" sets to upload pipeline requests via
+    /// NSFetchedResultsControllers. Each controller fires when un-uploaded items appear (or drop
+    /// out after a successful upload). We rely on the per-pipeline throttle so rapid changes
+    /// don't spam Nightscout.
+    func wireUploadControllers() {
+        determinationUploadControllerDelegate.onContentChange = { [weak self] in
+            self?.requestUpload(.deviceStatus)
+        }
+        overrideUploadControllerDelegate.onContentChange = { [weak self] in
+            self?.requestUpload(.overrides)
+        }
+        overrideRunUploadControllerDelegate.onContentChange = { [weak self] in
+            self?.requestUpload(.overrides)
+        }
+        tempTargetUploadControllerDelegate.onContentChange = { [weak self] in
+            self?.requestUpload(.tempTargets)
+        }
+        tempTargetRunUploadControllerDelegate.onContentChange = { [weak self] in
+            self?.requestUpload(.tempTargets)
+        }
+        pumpEventUploadControllerDelegate.onContentChange = { [weak self] in
+            self?.requestUpload(.pumpHistory)
+        }
+        carbEntryUploadControllerDelegate.onContentChange = { [weak self] in
+            self?.requestUpload(.carbs)
+        }
+        glucoseUploadControllerDelegate.onContentChange = { [weak self] in
+            self?.requestUpload(.glucose)
+        }
 
-        coreDataPublisher?
-            .filteredByEntityName("PumpEventStored")
-            .sink { [weak self] _ in self?.requestUpload(.pumpHistory) }
-            .store(in: &subscriptions)
-
-        coreDataPublisher?
-            .filteredByEntityName("CarbEntryStored")
-            .sink { [weak self] _ in self?.requestUpload(.carbs) }
-            .store(in: &subscriptions)
-
-        coreDataPublisher?
-            .filteredByEntityName("GlucoseStored")
-            .sink { [weak self] _ in
-                self?.requestUpload(.glucose)
+        // performFetch must run on the viewContext's queue (main).
+        Task { @MainActor in
+            do {
+                try self.determinationUploadController.performFetch()
+                try self.overrideUploadController.performFetch()
+                try self.overrideRunUploadController.performFetch()
+                try self.tempTargetUploadController.performFetch()
+                try self.tempTargetRunUploadController.performFetch()
+                try self.pumpEventUploadController.performFetch()
+                try self.carbEntryUploadController.performFetch()
+                try self.glucoseUploadController.performFetch()
+            } catch {
+                debug(.nightscout, "\(DebuggingIdentifiers.failed) Failed to set up Nightscout upload controllers: \(error)")
             }
-            .store(in: &subscriptions)
-    }
-
-    /// Glucose storage updates → request glucose pipeline
-    func wireGlucoseStorageSubscriber() {
-        glucoseStorage.updatePublisher
-            .receive(on: queue)
-            .sink { [weak self] _ in
-                self?.requestUpload(.glucose)
-            }
-            .store(in: &subscriptions)
+        }
     }
 }

+ 217 - 52
Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift

@@ -47,9 +47,6 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     // Queue where upload pipelines run.
     let uploadPipelineQueue = DispatchQueue(label: "NightscoutManager.uploadPipelines", qos: .utility)
 
-    // Background Core Data context for fetches used by upload tasks.
-    var backgroundContext = CoreDataStack.shared.newTaskContext()
-
     /// Throttle window (seconds) per upload pipeline. Any requests inside this window
     /// coalesce into a single upload run for that pipeline.
     let uploadPipelineInterval: [NightscoutUploadPipeline: TimeInterval] = [
@@ -130,26 +127,174 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     private var lastEnactedDetermination: Determination?
     private var lastSuggestedDetermination: Determination?
 
-    // Queue for handling Core Data change notifications
-    let queue = DispatchQueue(label: "BaseNightscoutManager.queue", qos: .utility)
-
-    /// Emits changed Core Data object IDs from the app. We filter by entity names
-    /// and request upload pipelines based on what changed.
-    var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
-
     /// Bag for Combine subscriptions owned by this manager.
     var subscriptions = Set<AnyCancellable>()
 
+    let viewContext = CoreDataStack.shared.persistentContainer.viewContext
+
+    // MARK: - Upload triggers
+
+    //
+    // Each upload pipeline is driven by an NSFetchedResultsController whose predicate is the
+    // "not yet uploaded to Nightscout" set for that entity. The controller fires whenever
+    // un-uploaded items appear (or drop out after a successful upload), which we map to a
+    // `requestUpload(pipeline)` call (throttled per pipeline). Bound to the viewContext, they
+    // also pick up batch-inserted glucose via the persistent history merge in CoreDataStack —
+    // replacing the previous changedObjects publisher plus the glucoseStorage.updatePublisher
+    // fallback.
+
+    let determinationUploadControllerDelegate = FetchedResultsControllerDelegate()
+    lazy var determinationUploadController: NSFetchedResultsController<OrefDetermination> = {
+        let request = NSFetchRequest<OrefDetermination>(entityName: "OrefDetermination")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \OrefDetermination.deliverAt, ascending: true)]
+        request.predicate = NSPredicate(
+            format: "deliverAt >= %@ AND isUploadedToNS == %@",
+            Date.oneDayAgo as NSDate,
+            false as NSNumber
+        )
+        request.fetchBatchSize = 50
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = determinationUploadControllerDelegate
+        return controller
+    }()
+
+    let overrideUploadControllerDelegate = FetchedResultsControllerDelegate()
+    lazy var overrideUploadController: NSFetchedResultsController<OverrideStored> = {
+        let request = NSFetchRequest<OverrideStored>(entityName: "OverrideStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \OverrideStored.date, ascending: true)]
+        request.predicate = NSPredicate(
+            format: "date >= %@ AND isUploadedToNS == %@",
+            Date.oneDayAgo as NSDate,
+            false as NSNumber
+        )
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = overrideUploadControllerDelegate
+        return controller
+    }()
+
+    let overrideRunUploadControllerDelegate = FetchedResultsControllerDelegate()
+    lazy var overrideRunUploadController: NSFetchedResultsController<OverrideRunStored> = {
+        let request = NSFetchRequest<OverrideRunStored>(entityName: "OverrideRunStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \OverrideRunStored.startDate, ascending: true)]
+        request.predicate = NSPredicate(
+            format: "startDate >= %@ AND isUploadedToNS == %@",
+            Date.oneDayAgo as NSDate,
+            false as NSNumber
+        )
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = overrideRunUploadControllerDelegate
+        return controller
+    }()
+
+    let tempTargetUploadControllerDelegate = FetchedResultsControllerDelegate()
+    lazy var tempTargetUploadController: NSFetchedResultsController<TempTargetStored> = {
+        let request = NSFetchRequest<TempTargetStored>(entityName: "TempTargetStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \TempTargetStored.date, ascending: true)]
+        request.predicate = NSPredicate(
+            format: "date >= %@ AND isUploadedToNS == %@",
+            Date.oneDayAgo as NSDate,
+            false as NSNumber
+        )
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = tempTargetUploadControllerDelegate
+        return controller
+    }()
+
+    let tempTargetRunUploadControllerDelegate = FetchedResultsControllerDelegate()
+    lazy var tempTargetRunUploadController: NSFetchedResultsController<TempTargetRunStored> = {
+        let request = NSFetchRequest<TempTargetRunStored>(entityName: "TempTargetRunStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \TempTargetRunStored.startDate, ascending: true)]
+        request.predicate = NSPredicate(
+            format: "startDate >= %@ AND isUploadedToNS == %@",
+            Date.oneDayAgo as NSDate,
+            false as NSNumber
+        )
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = tempTargetRunUploadControllerDelegate
+        return controller
+    }()
+
+    let pumpEventUploadControllerDelegate = FetchedResultsControllerDelegate()
+    lazy var pumpEventUploadController: NSFetchedResultsController<PumpEventStored> = {
+        let request = NSFetchRequest<PumpEventStored>(entityName: "PumpEventStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \PumpEventStored.timestamp, ascending: true)]
+        request.predicate = NSPredicate.pumpEventsNotYetUploadedToNightscout
+        request.fetchBatchSize = 50
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = pumpEventUploadControllerDelegate
+        return controller
+    }()
+
+    let carbEntryUploadControllerDelegate = FetchedResultsControllerDelegate()
+    lazy var carbEntryUploadController: NSFetchedResultsController<CarbEntryStored> = {
+        let request = NSFetchRequest<CarbEntryStored>(entityName: "CarbEntryStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \CarbEntryStored.date, ascending: true)]
+        request.predicate = NSPredicate(
+            format: "date >= %@ AND isUploadedToNS == %@",
+            Date.oneDayAgo as NSDate,
+            false as NSNumber
+        )
+        request.fetchBatchSize = 50
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = carbEntryUploadControllerDelegate
+        return controller
+    }()
+
+    let glucoseUploadControllerDelegate = FetchedResultsControllerDelegate()
+    lazy var glucoseUploadController: NSFetchedResultsController<GlucoseStored> = {
+        let request = NSFetchRequest<GlucoseStored>(entityName: "GlucoseStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \GlucoseStored.date, ascending: true)]
+        request.predicate = NSPredicate.glucoseNotYetUploadedToNightscout
+        request.fetchBatchSize = 50
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = glucoseUploadControllerDelegate
+        return controller
+    }()
+
     init(resolver: Resolver) {
         injectServices(resolver)
         subscribe()
 
-        coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
-                .receive(on: queue)
-                .share()
-                .eraseToAnyPublisher()
-
         setupNotification()
 
         setupLanePipelines()
@@ -356,9 +501,11 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     private func fetchBattery() async -> Battery {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchBattery"
+        return await context.perform {
             do {
-                let results = try self.backgroundContext.fetch(OpenAPS_Battery.fetch(NSPredicate.predicateFor30MinAgo))
+                let results = try context.fetch(OpenAPS_Battery.fetch(NSPredicate.predicateFor30MinAgo))
                 if let last = results.first {
                     let percent: Int? = Int(last.percent)
                     let voltage: Decimal? = last.voltage as Decimal?
@@ -414,16 +561,18 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
             return
         }
 
+        let tddContext = CoreDataStack.shared.newTaskContext()
+        tddContext.name = "uploadDeviceStatus.tdd"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TDDStored.self,
-            onContext: backgroundContext,
+            onContext: tddContext,
             predicate: NSPredicate.predicateFor30MinAgo,
             key: "date",
             ascending: false,
             fetchLimit: 1
         )
 
-        let tdd: Decimal? = await backgroundContext.perform {
+        let tdd: Decimal? = await tddContext.perform {
             (results as? [TDDStored])?.first?.total as? Decimal
         }
 
@@ -588,19 +737,21 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     private func updateOrefDeterminationAsUploaded(_ determination: [Determination]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateOrefDeterminationAsUploaded"
+        await context.perform {
             let ids = determination.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<OrefDetermination> = OrefDetermination.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToNS = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
@@ -843,19 +994,21 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     private func updateGlucoseAsUploaded(_ glucose: [BloodGlucose]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateGlucoseAsUploaded"
+        await context.perform {
             let ids = glucose.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToNS = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
@@ -899,19 +1052,21 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     private func updatePumpEventStoredsAsUploaded(_ treatments: [NightscoutTreatment]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updatePumpEventStoredsAsUploaded"
+        await context.perform {
             let ids = treatments.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToNS = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
@@ -940,19 +1095,21 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     private func updateCarbsAsUploaded(_ treatments: [NightscoutTreatment]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateCarbsAsUploaded"
+        await context.perform {
             let ids = treatments.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<CarbEntryStored> = CarbEntryStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToNS = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
@@ -999,19 +1156,21 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     private func updateOverridesAsUploaded(_ overrides: [NightscoutExercise]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateOverridesAsUploaded"
+        await context.perform {
             let ids = overrides.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<OverrideStored> = OverrideStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToNS = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
@@ -1057,19 +1216,21 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     private func updateOverrideRunsAsUploaded(_ overrideRuns: [NightscoutExercise]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateOverrideRunsAsUploaded"
+        await context.perform {
             let ids = overrideRuns.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<OverrideRunStored> = OverrideRunStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToNS = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
@@ -1098,19 +1259,21 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     private func updateTempTargetsAsUploaded(_ tempTargets: [NightscoutTreatment]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateTempTargetsAsUploaded"
+        await context.perform {
             let ids = tempTargets.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<TempTargetStored> = TempTargetStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToNS = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS for TempTargetStored: \(error.userInfo)"
@@ -1139,19 +1302,21 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     private func updateTempTargetRunsAsUploaded(_ tempTargetRuns: [NightscoutTreatment]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateTempTargetRunsAsUploaded"
+        await context.perform {
             let ids = tempTargetRuns.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<TempTargetRunStored> = TempTargetRunStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToNS = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS for TempTargetRunStored: \(error.userInfo)"

+ 173 - 136
Trio/Sources/Services/Network/TidepoolManager.swift

@@ -59,12 +59,65 @@ final class BaseTidepoolManager: TidepoolManager, Injectable {
         }
     }
 
-    private var backgroundContext = CoreDataStack.shared.newTaskContext()
-
-    // Queue for handling Core Data change notifications
-    private let queue = DispatchQueue(label: "BaseTidepoolManager.queue", qos: .background)
-    private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
-    private var subscriptions = Set<AnyCancellable>()
+    let viewContext = CoreDataStack.shared.persistentContainer.viewContext
+
+    // MARK: - Upload triggers
+
+    //
+    // Each upload pipeline is driven by an NSFetchedResultsController whose predicate is the
+    // "not yet uploaded to Tidepool" set. The controller fires whenever un-uploaded items appear
+    // (or drop out after a successful upload), which we use to (re-)trigger the matching upload.
+    // Bound to the viewContext, it also picks up batch-inserted glucose via the persistent history
+    // merge in CoreDataStack — replacing the previous changedObjects publisher plus the separate
+    // glucoseStorage.updatePublisher fallback.
+
+    let glucoseUploadControllerDelegate = FetchedResultsControllerDelegate()
+    private lazy var glucoseUploadController: NSFetchedResultsController<GlucoseStored> = {
+        let request = NSFetchRequest<GlucoseStored>(entityName: "GlucoseStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \GlucoseStored.date, ascending: true)]
+        request.predicate = NSPredicate.glucoseNotYetUploadedToTidepool
+        request.fetchBatchSize = 50
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = glucoseUploadControllerDelegate
+        return controller
+    }()
+
+    let carbsUploadControllerDelegate = FetchedResultsControllerDelegate()
+    private lazy var carbsUploadController: NSFetchedResultsController<CarbEntryStored> = {
+        let request = NSFetchRequest<CarbEntryStored>(entityName: "CarbEntryStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \CarbEntryStored.date, ascending: true)]
+        request.predicate = NSPredicate.carbsNotYetUploadedToTidepool
+        request.fetchBatchSize = 50
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = carbsUploadControllerDelegate
+        return controller
+    }()
+
+    let insulinUploadControllerDelegate = FetchedResultsControllerDelegate()
+    private lazy var insulinUploadController: NSFetchedResultsController<PumpEventStored> = {
+        let request = NSFetchRequest<PumpEventStored>(entityName: "PumpEventStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \PumpEventStored.timestamp, ascending: true)]
+        request.predicate = NSPredicate.pumpEventsNotYetUploadedToTidepool
+        request.fetchBatchSize = 50
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = insulinUploadControllerDelegate
+        return controller
+    }()
 
     @PersistedProperty(key: "TidepoolState") var rawTidepoolManager: Service.RawValue?
 
@@ -73,23 +126,33 @@ final class BaseTidepoolManager: TidepoolManager, Injectable {
         injectServices(resolver)
         loadTidepoolManager()
 
-        coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
-                .receive(on: queue)
-                .share()
-                .eraseToAnyPublisher()
+        registerUploadControllers()
+    }
 
-        glucoseStorage.updatePublisher
-            .receive(on: DispatchQueue.global(qos: .background))
-            .sink { [weak self] _ in
-                guard let self = self else { return }
-                Task {
-                    await self.uploadGlucose()
-                }
+    private func registerUploadControllers() {
+        glucoseUploadControllerDelegate.onContentChange = { [weak self] in
+            Task { await self?.uploadGlucose() }
+        }
+        carbsUploadControllerDelegate.onContentChange = { [weak self] in
+            Task { await self?.uploadCarbs() }
+        }
+        insulinUploadControllerDelegate.onContentChange = { [weak self] in
+            Task { await self?.uploadInsulin() }
+        }
+
+        // performFetch must run on the viewContext's queue (main).
+        Task { @MainActor in
+            do {
+                try self.glucoseUploadController.performFetch()
+                try self.carbsUploadController.performFetch()
+                try self.insulinUploadController.performFetch()
+            } catch {
+                debug(.service, "\(DebuggingIdentifiers.failed) Failed to set up Tidepool upload controllers: \(error)")
             }
-            .store(in: &subscriptions)
+        }
 
-        registerHandlers()
+        broadcaster.register(SettingsObserver.self, observer: self)
+        broadcaster.register(PreferencesObserver.self, observer: self)
     }
 
     /// Loads the Tidepool service from saved state
@@ -128,38 +191,6 @@ final class BaseTidepoolManager: TidepoolManager, Injectable {
         return nil
     }
 
-    /// Registers handlers for Core Data changes
-    private func registerHandlers() {
-        coreDataPublisher?.filteredByEntityName("PumpEventStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task { [weak self] in
-                guard let self = self else { return }
-                await self.uploadInsulin()
-            }
-        }.store(in: &subscriptions)
-
-        coreDataPublisher?.filteredByEntityName("CarbEntryStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task { [weak self] in
-                guard let self = self else { return }
-                await self.uploadCarbs()
-            }
-        }.store(in: &subscriptions)
-
-        // This works only for manual Glucose
-        coreDataPublisher?.filteredByEntityName("GlucoseStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task { [weak self] in
-                guard let self = self else { return }
-                await self.uploadGlucose()
-            }
-        }.store(in: &subscriptions)
-
-        // Register for settings that aren't saved from a single editor screen
-        broadcaster.register(SettingsObserver.self, observer: self)
-        broadcaster.register(PreferencesObserver.self, observer: self)
-    }
-
     func sourceInfo() -> [String: Any]? {
         nil
     }
@@ -246,19 +277,21 @@ extension BaseTidepoolManager {
     }
 
     private func updateCarbsAsUploaded(_ carbs: [CarbsEntry]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateCarbsAsUploaded"
+        await context.perform {
             let ids = carbs.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<CarbEntryStored> = CarbEntryStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToTidepool = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToTidepool: \(error.userInfo)"
@@ -316,10 +349,12 @@ extension BaseTidepoolManager {
         guard !events.isEmpty, let tidepoolService = self.tidepoolService else { return }
 
         do {
+            let context = CoreDataStack.shared.newTaskContext()
+            context.name = "uploadDose"
             // Fetch all temp basal entries from Core Data for the last 24 hours
             let results = try await CoreDataStack.shared.fetchEntitiesAsync(
                 ofType: PumpEventStored.self,
-                onContext: backgroundContext,
+                onContext: context,
                 predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [
                     NSPredicate.pumpHistoryLast24h,
                     NSPredicate(format: "tempBasal != nil")
@@ -330,7 +365,7 @@ extension BaseTidepoolManager {
             )
 
             // Ensure that the processing happens within the background context for thread safety
-            try await backgroundContext.perform {
+            try await context.perform {
                 guard let existingTempBasalEntries = results as? [PumpEventStored] else {
                     throw CoreDataError.fetchError(function: #function, file: #file)
                 }
@@ -432,19 +467,21 @@ extension BaseTidepoolManager {
     }
 
     private func updateInsulinAsUploaded(_ insulin: [PumpHistoryEvent]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateInsulinAsUploaded"
+        await context.perform {
             let ids = insulin.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToTidepool = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToTidepool: \(error.userInfo)"
@@ -486,79 +523,77 @@ extension BaseTidepoolManager {
     ) -> [DoseEntry] {
         var insulinDoseEvents: [DoseEntry] = []
 
-        backgroundContext.performAndWait {
-            // Loop through the pump history events within the background context
-            guard let duration = event.duration, let amount = event.amount,
-                  let currentBasalRate = self.getCurrentBasalRate()
-            else {
-                return
-            }
-            let value = (Decimal(duration) / 60.0) * amount
-
-            // Find the corresponding temp basal entry in existingTempBasalEntries
-            if let matchingEntryIndex = existingTempBasalEntries.firstIndex(where: { $0.timestamp == event.timestamp }) {
-                // Check for a predecessor (the entry before the matching entry)
-                let predecessorIndex = matchingEntryIndex - 1
-                if predecessorIndex >= 0 {
-                    let predecessorEntry = existingTempBasalEntries[predecessorIndex]
-                    if let predecessorTimestamp = predecessorEntry.timestamp,
-                       let predecessorEntrySyncIdentifier = predecessorEntry.id
-                    {
-                        let predecessorEndDate = predecessorTimestamp
-                            .addingTimeInterval(TimeInterval(
-                                Int(predecessorEntry.tempBasal?.duration ?? 0) *
-                                    60
-                            )) // parse duration to minutes
-
-                        // If the predecessor's end date is later than the current event's start date, adjust it
-                        if predecessorEndDate > event.timestamp {
-                            let adjustedEndDate = event.timestamp
-                            let adjustedDuration = adjustedEndDate.timeIntervalSince(predecessorTimestamp)
-                            let adjustedDeliveredUnits = (adjustedDuration / 3600) *
-                                Double(truncating: predecessorEntry.tempBasal?.rate ?? 0)
-
-                            // Create updated predecessor dose entry
-                            let updatedPredecessorEntry = DoseEntry(
-                                type: .tempBasal,
-                                startDate: predecessorTimestamp,
-                                endDate: adjustedEndDate,
-                                value: adjustedDeliveredUnits,
-                                unit: .units,
-                                deliveredUnits: adjustedDeliveredUnits,
-                                syncIdentifier: predecessorEntrySyncIdentifier,
-                                insulinType: self.apsManager.pumpManager?.status.insulinType ?? nil,
-                                automatic: true,
-                                manuallyEntered: false,
-                                isMutable: false
-                            )
-                            // Add the updated predecessor entry to the result
-                            insulinDoseEvents.append(updatedPredecessorEntry)
-                        }
+        // Caller (uploadDose) already executes within context.perform, so we run directly here
+        guard let duration = event.duration, let amount = event.amount,
+              let currentBasalRate = getCurrentBasalRate()
+        else {
+            return insulinDoseEvents
+        }
+        let value = (Decimal(duration) / 60.0) * amount
+
+        // Find the corresponding temp basal entry in existingTempBasalEntries
+        if let matchingEntryIndex = existingTempBasalEntries.firstIndex(where: { $0.timestamp == event.timestamp }) {
+            // Check for a predecessor (the entry before the matching entry)
+            let predecessorIndex = matchingEntryIndex - 1
+            if predecessorIndex >= 0 {
+                let predecessorEntry = existingTempBasalEntries[predecessorIndex]
+                if let predecessorTimestamp = predecessorEntry.timestamp,
+                   let predecessorEntrySyncIdentifier = predecessorEntry.id
+                {
+                    let predecessorEndDate = predecessorTimestamp
+                        .addingTimeInterval(TimeInterval(
+                            Int(predecessorEntry.tempBasal?.duration ?? 0) *
+                                60
+                        )) // parse duration to minutes
+
+                    // If the predecessor's end date is later than the current event's start date, adjust it
+                    if predecessorEndDate > event.timestamp {
+                        let adjustedEndDate = event.timestamp
+                        let adjustedDuration = adjustedEndDate.timeIntervalSince(predecessorTimestamp)
+                        let adjustedDeliveredUnits = (adjustedDuration / 3600) *
+                            Double(truncating: predecessorEntry.tempBasal?.rate ?? 0)
+
+                        // Create updated predecessor dose entry
+                        let updatedPredecessorEntry = DoseEntry(
+                            type: .tempBasal,
+                            startDate: predecessorTimestamp,
+                            endDate: adjustedEndDate,
+                            value: adjustedDeliveredUnits,
+                            unit: .units,
+                            deliveredUnits: adjustedDeliveredUnits,
+                            syncIdentifier: predecessorEntrySyncIdentifier,
+                            insulinType: apsManager.pumpManager?.status.insulinType ?? nil,
+                            automatic: true,
+                            manuallyEntered: false,
+                            isMutable: false
+                        )
+                        // Add the updated predecessor entry to the result
+                        insulinDoseEvents.append(updatedPredecessorEntry)
                     }
                 }
-
-                // Create a new dose entry for the current event
-                let currentEndDate = event.timestamp.addingTimeInterval(TimeInterval(minutes: Double(duration)))
-                let newDoseEntry = DoseEntry(
-                    type: .tempBasal,
-                    startDate: event.timestamp,
-                    endDate: currentEndDate,
-                    value: Double(value),
-                    unit: .units,
-                    deliveredUnits: Double(value),
-                    syncIdentifier: event.id,
-                    scheduledBasalRate: HKQuantity(
-                        unit: .internationalUnitsPerHour,
-                        doubleValue: Double(currentBasalRate.rate)
-                    ),
-                    insulinType: self.apsManager.pumpManager?.status.insulinType ?? nil,
-                    automatic: true,
-                    manuallyEntered: false,
-                    isMutable: false
-                )
-                // Add the new event entry to the result
-                insulinDoseEvents.append(newDoseEntry)
             }
+
+            // Create a new dose entry for the current event
+            let currentEndDate = event.timestamp.addingTimeInterval(TimeInterval(minutes: Double(duration)))
+            let newDoseEntry = DoseEntry(
+                type: .tempBasal,
+                startDate: event.timestamp,
+                endDate: currentEndDate,
+                value: Double(value),
+                unit: .units,
+                deliveredUnits: Double(value),
+                syncIdentifier: event.id,
+                scheduledBasalRate: HKQuantity(
+                    unit: .internationalUnitsPerHour,
+                    doubleValue: Double(currentBasalRate.rate)
+                ),
+                insulinType: apsManager.pumpManager?.status.insulinType ?? nil,
+                automatic: true,
+                manuallyEntered: false,
+                isMutable: false
+            )
+            // Add the new event entry to the result
+            insulinDoseEvents.append(newDoseEntry)
         }
 
         return insulinDoseEvents
@@ -651,19 +686,21 @@ extension BaseTidepoolManager {
     }
 
     private func updateGlucoseAsUploaded(_ glucose: [StoredGlucoseSample]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateGlucoseAsUploaded"
+        await context.perform {
             let ids = glucose.map(\.syncIdentifier) as NSArray
             let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToTidepool = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToTidepool: \(error.userInfo)"

+ 8 - 0
Trio/Sources/Services/Notifications/SwiftNotificationCenter/WeakObjectSet.swift

@@ -41,6 +41,9 @@ struct WeakObjectSet<T: AnyObject>: Sequence {
     }
 
     mutating func add(_ object: T) {
+        // Registration doubles as the reaping hook — dead boxes are removed nowhere
+        // else, and most observers never unregister explicitly.
+        compact()
         // prevent ObjectIdentifier be reused
         if contains(object) {
             remove(object)
@@ -48,6 +51,11 @@ struct WeakObjectSet<T: AnyObject>: Sequence {
         objects.insert(WeakObject(object))
     }
 
+    /// Drops entries whose weakly-held object has been deallocated.
+    mutating func compact() {
+        objects = objects.filter { $0.object != nil }
+    }
+
     mutating func add(_ objects: [T]) {
         objects.forEach { self.add($0) }
     }

+ 5 - 4
Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift

@@ -35,7 +35,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     private let notificationCenter = UNUserNotificationCenter.current()
 
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-    private let backgroundContext = CoreDataStack.shared.newTaskContext()
 
     // Queue for handling Core Data change notifications
     private let queue = DispatchQueue(label: "BaseUserNotificationsManager.queue", qos: .userInitiated)
@@ -48,7 +47,7 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         injectServices(resolver)
 
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()
@@ -130,16 +129,18 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     }
 
     private func fetchGlucoseIDs() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchGlucoseIDs"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.predicateFor20MinAgo,
             key: "date",
             ascending: false,
             fetchLimit: 3
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }

+ 22 - 13
Trio/Sources/Services/WatchManager/AppleWatchManager.swift

@@ -41,7 +41,6 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
     typealias PumpEvent = PumpEventStored.EventType
 
-    let backgroundContext = CoreDataStack.shared.newTaskContext()
     let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
     init(resolver: Resolver) {
@@ -61,7 +60,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
         // Observer for OrefDetermination and adjustments
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()
@@ -184,6 +183,9 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
             return WatchState(date: Date())
         }
         do {
+            let context = CoreDataStack.shared.newTaskContext()
+            context.name = "setupWatchState"
+
             // Get NSManagedObjectIDs
             let glucoseIds = try await fetchGlucose()
             let determinationIds = try await determinationStorage.fetchLastDeterminationObjectID(
@@ -194,15 +196,15 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
             // Get NSManagedObjects
             let glucoseObjects: [GlucoseStored] = try await CoreDataStack.shared
-                .getNSManagedObject(with: glucoseIds, context: backgroundContext)
+                .getNSManagedObject(with: glucoseIds, context: context)
             let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared
-                .getNSManagedObject(with: determinationIds, context: backgroundContext)
+                .getNSManagedObject(with: determinationIds, context: context)
             let overridePresetObjects: [OverrideStored] = try await CoreDataStack.shared
-                .getNSManagedObject(with: overridePresetIds, context: backgroundContext)
+                .getNSManagedObject(with: overridePresetIds, context: context)
             let tempTargetPresetObjects: [TempTargetStored] = try await CoreDataStack.shared
-                .getNSManagedObject(with: tempTargetPresetIds, context: backgroundContext)
+                .getNSManagedObject(with: tempTargetPresetIds, context: context)
 
-            return await backgroundContext.perform {
+            return await context.perform {
                 var watchState = WatchState(date: Date())
 
                 // Set lastLoopDate
@@ -374,15 +376,17 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
     /// Fetches recent glucose readings from CoreData
     /// - Returns: Array of NSManagedObjectIDs for glucose readings
     private func fetchGlucose() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchGlucose"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.glucose,
             key: "date",
             ascending: false
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -394,16 +398,19 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
     /// Fetches last pump event that is a non-external bolus from CoreData
     /// - Returns: NSManagedObjectIDs for last bolus
     func fetchLastBolus() async throws -> NSManagedObjectID? {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchLastBolus"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.lastPumpBolus,
             key: "timestamp",
             ascending: false,
-            fetchLimit: 1
+            fetchLimit: 1,
+            relationshipKeyPathsForPrefetching: ["bolus"]
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [PumpEventStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -640,13 +647,15 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                     guard let self = self else { return }
 
                     do {
+                        let context = CoreDataStack.shared.newTaskContext()
+                        context.name = "requestBolusRecommendation"
                         // Fetch determination data
                         let determinationIds = try await determinationStorage.fetchLastDeterminationObjectID(
                             predicate: NSPredicate.predicateFor30MinAgoForDetermination
                         )
                         let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared.getNSManagedObject(
                             with: determinationIds,
-                            context: backgroundContext
+                            context: context
                         )
 
                         await MainActor.run {

+ 17 - 12
Trio/Sources/Services/WatchManager/GarminManager.swift

@@ -158,9 +158,6 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
     /// Additional local subscriptions (separate from `cancellables`) for CoreData events.
     private var subscriptions = Set<AnyCancellable>()
 
-    /// Represents the context for background tasks in CoreData.
-    let backgroundContext = CoreDataStack.shared.newTaskContext()
-
     /// Represents the main (view) context for CoreData, typically used on the main thread.
     let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
@@ -198,7 +195,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
         broadcaster.register(SettingsObserver.self, observer: self)
 
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()
@@ -456,16 +453,18 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
     /// - Parameter limit: Maximum number of glucose entries to fetch (default: 2)
     /// - Returns: An array of `NSManagedObjectID`s for glucose readings.
     private func fetchGlucose(limit: Int = 2) async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchGlucose"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.glucose,
             key: "date",
             ascending: false,
             fetchLimit: limit
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -476,6 +475,8 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
     /// Fetches the most recent temporary basal rate from CoreData pump history.
     /// - Returns: An array containing the NSManagedObjectID of the latest temp basal event, if any.
     private func fetchTempBasals() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchTempBasals"
         let tempBasalPredicate = NSPredicate(format: "tempBasal != nil")
         let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
             NSPredicate.pumpHistoryLast24h,
@@ -484,14 +485,15 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
 
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: compoundPredicate,
             key: "timestamp",
             ascending: false,
-            fetchLimit: 1
+            fetchLimit: 1,
+            relationshipKeyPathsForPrefetching: ["tempBasal"]
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let pumpEvents = results as? [PumpEventStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -503,16 +505,18 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
     /// Returns them sorted newest first, allowing us to find both enacted and suggested determinations.
     /// - Returns: An array of `NSManagedObjectID`s for all determinations in the 30-minute window.
     private func fetchDeterminations30Min() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchDeterminations30Min"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.predicateFor30MinAgoForDetermination,
             key: "deliverAt",
             ascending: false,
             fetchLimit: 0 // No limit - get all determinations in 30min window
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [OrefDetermination] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -558,7 +562,8 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
         let previousWatchState = lastPreparedWatchState
 
         // Capture context locally for use in perform block
-        let context = backgroundContext
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "setupGarminWatchState"
 
         let watchStates = await context.perform {
             // Fetch Core Data objects inside perform block

+ 0 - 1
Trio/Sources/Shortcuts/BaseIntentsRequest.swift

@@ -21,7 +21,6 @@ import Swinject
 
     let resolver: Resolver
 
-    let coredataContext = CoreDataStack.shared.newTaskContext()
     let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
     override init() {

+ 15 - 6
Trio/Sources/Shortcuts/Override/OverridePresetsIntentRequest.swift

@@ -18,14 +18,17 @@ import UIKit
      - Throws: An error if fetching fails or Core Data operations fail.
      */
     func fetchAndProcessOverrides() async throws -> [OverridePreset] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchAndProcessOverrides"
+
         do {
             // Fetch all Override Presets via OverrideStorage
             let allOverridePresetsIDs = try await overrideStorage.fetchForOverridePresets()
 
             // Since we are fetching on a different background Thread we need to unpack the NSManagedObjectID on the correct Thread first
-            return try await coredataContext.perform {
+            return try await context.perform {
                 let overrideObjects = try allOverridePresetsIDs.compactMap { id in
-                    try self.coredataContext.existingObject(with: id) as? OverrideStored
+                    try context.existingObject(with: id) as? OverrideStored
                 }
 
                 return overrideObjects.map { object in
@@ -51,12 +54,15 @@ import UIKit
      - Throws: `overridePresetsError.noTempOverrideFound` if no presets are found.
      */
     func fetchIDs(_ uuid: [OverridePreset.ID]) async throws -> [OverridePreset] {
-        try await coredataContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchIDs"
+
+        return try await context.perform {
             let fetchRequest: NSFetchRequest<OverrideStored> = OverrideStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", uuid)
 
             do {
-                let result = try self.coredataContext.fetch(fetchRequest)
+                let result = try context.fetch(fetchRequest)
 
                 if result.isEmpty {
                     debug(
@@ -87,12 +93,15 @@ import UIKit
      - Throws: `overridePresetsError.noTempOverrideFound` if the preset is not found.
      */
     private func fetchOverrideID(_ preset: OverridePreset) async throws -> NSManagedObjectID {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchOverrideID"
+
         let fetchRequest: NSFetchRequest<OverrideStored> = OverrideStored.fetchRequest()
         fetchRequest.predicate = NSPredicate(format: "id == %@", preset.id)
         fetchRequest.fetchLimit = 1
 
-        return try await coredataContext.perform {
-            guard let objectID = try self.coredataContext.fetch(fetchRequest).first?.objectID else {
+        return try await context.perform {
+            guard let objectID = try context.fetch(fetchRequest).first?.objectID else {
                 debug(
                     .default,
                     "\(DebuggingIdentifiers.failed) No override found for preset: \(preset.name)"

+ 0 - 2
Trio/Sources/Shortcuts/State/StateIntentRequest.swift

@@ -55,8 +55,6 @@ struct StateBGQuery: EntityQuery {
 }
 
 final class StateIntentRequest: BaseIntentsRequest {
-    let moc = CoreDataStack.shared.newTaskContext()
-
     func getLastGlucose(onContext: NSManagedObjectContext) throws
         -> (dateGlucose: Date, glucose: String, trend: String, delta: String)
     {

+ 18 - 7
Trio/Sources/Shortcuts/TempPresets/TempPresetsIntentRequest.swift

@@ -18,14 +18,17 @@ final class TempPresetsIntentRequest: BaseIntentsRequest {
     /// - Returns: An array of `TempPreset` objects.
     /// - Throws: An error if fetching or processing fails.
     func fetchAndProcessTempTargets() async throws -> [TempPreset] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchAndProcessTempTargets"
+
         // Fetch all Temp Target Presets via TempTargetStorage
         let allTempTargetPresetsIDs = try await tempTargetsStorage.fetchForTempTargetPresets()
 
         // Perform the fetch and process on the Core Data context's thread
-        return try await coredataContext.perform {
+        return try await context.perform {
             // Fetch existing TempTargetStored objects based on their NSManagedObjectIDs
             let tempTargetObjects: [TempTargetStored] = allTempTargetPresetsIDs.compactMap { id in
-                guard let object = try? self.coredataContext.existingObject(with: id) as? TempTargetStored else {
+                guard let object = try? context.existingObject(with: id) as? TempTargetStored else {
                     debugPrint("\(#file) \(#function) Failed to fetch object for ID: \(id)")
                     return nil
                 }
@@ -52,12 +55,15 @@ final class TempPresetsIntentRequest: BaseIntentsRequest {
     /// - Parameter uuid: An array of preset IDs to fetch.
     /// - Returns: An array of `TempPreset` objects.
     func fetchIDs(_ uuid: [TempPreset.ID]) async -> [TempPreset] {
-        await coredataContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchIDs"
+
+        return await context.perform {
             let fetchRequest: NSFetchRequest<TempTargetStored> = TempTargetStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", uuid)
 
             do {
-                let result = try self.coredataContext.fetch(fetchRequest)
+                let result = try context.fetch(fetchRequest)
 
                 if result.isEmpty {
                     debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) No TempTargetStored found for ids: \(uuid)")
@@ -84,14 +90,19 @@ final class TempPresetsIntentRequest: BaseIntentsRequest {
     ///
     /// - Parameter preset: The `TempPreset` to find.
     /// - Returns: The `NSManagedObjectID` of the temp target if found, otherwise `nil`.
-    private func fetchTempTargetID(_ preset: TempPreset) async -> NSManagedObjectID? {
+    private func fetchTempTargetID(_ preset: TempPreset) async ->
+        NSManagedObjectID?
+    {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchTempTargetID"
+
         let fetchRequest: NSFetchRequest<TempTargetStored> = TempTargetStored.fetchRequest()
         fetchRequest.predicate = NSPredicate(format: "id == %@", preset.id.uuidString)
         fetchRequest.fetchLimit = 1
 
-        return await coredataContext.perform {
+        return await context.perform {
             do {
-                return try self.coredataContext.fetch(fetchRequest).first?.objectID
+                return try context.fetch(fetchRequest).first?.objectID
             } catch {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch Temp Target: \(error)"

+ 27 - 1
Trio/Sources/Views/SnoozeAlertsSheetView.swift

@@ -26,7 +26,16 @@ struct SnoozeAlertsSheetView: View {
                             ))
                                 .font(.headline)
                         }
-                    }.listRowBackground(Color.chart)
+                        .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+                            endSnoozeAction
+                        }
+                        .listRowBackground(Color.chart)
+                    } footer: {
+                        HStack {
+                            Image(systemName: "hand.draw.fill").foregroundStyle(.primary)
+                            Text("Swipe left to end snooze.")
+                        }
+                    }
                 }
                 Section(footer: Text(
                     "Pick a duration to mute every Trio alarm. Critical alerts (e.g. occlusion, urgent low) still pierce the snooze."
@@ -62,6 +71,15 @@ struct SnoozeAlertsSheetView: View {
         }
     }
 
+    private var endSnoozeAction: some View {
+        Button(role: .destructive) {
+            endSnooze()
+        } label: {
+            Label("End Snooze", systemImage: "alarm.waves.left.and.right.fill")
+        }
+        .tint(.red)
+    }
+
     private func applySnooze(_ duration: TimeInterval) {
         let trioAlertManager = resolver.resolve(TrioAlertManager.self)
         Task { @MainActor in
@@ -70,4 +88,12 @@ struct SnoozeAlertsSheetView: View {
             isPresented = false
         }
     }
+
+    private func endSnooze() {
+        let trioAlertManager = resolver.resolve(TrioAlertManager.self)
+        Task { @MainActor in
+            await trioAlertManager?.applySnooze(for: 0)
+            snoozeUntilDate = Date().addingTimeInterval(0)
+        }
+    }
 }

+ 6 - 6
TrioTests/CoreDataTests/TestAssembly.swift

@@ -13,32 +13,32 @@ class TestAssembly: Assembly {
     func assemble(container: Container) {
         // Override PumpHistoryStorage registration for tests
         container.register(PumpHistoryStorage.self) { r in
-            BasePumpHistoryStorage(resolver: r, context: self.testContext)
+            BasePumpHistoryStorage(resolver: r, contextProvider: { self.testContext })
         }.inObjectScope(.container)
 
         // Override DeterminationStorage registration for tests
         container.register(DeterminationStorage.self) { r in
-            BaseDeterminationStorage(resolver: r, context: self.testContext)
+            BaseDeterminationStorage(resolver: r, contextProvider: { self.testContext })
         }.inObjectScope(.container)
 
         // Override CarbsStorage registration for tests
         container.register(CarbsStorage.self) { r in
-            BaseCarbsStorage(resolver: r, context: self.testContext)
+            BaseCarbsStorage(resolver: r, contextProvider: { self.testContext })
         }.inObjectScope(.container)
 
         // Override GlucoseStorage registration for tests
         container.register(GlucoseStorage.self) { r in
-            BaseGlucoseStorage(resolver: r, context: self.testContext)
+            BaseGlucoseStorage(resolver: r, contextProvider: { self.testContext })
         }.inObjectScope(.container)
 
         // Override TempTargetStorage registration for tests
         container.register(TempTargetsStorage.self) { r in
-            BaseTempTargetsStorage(resolver: r, context: self.testContext)
+            BaseTempTargetsStorage(resolver: r, contextProvider: { self.testContext })
         }.inObjectScope(.container)
 
         // Override OverrideStorage registration for tests
         container.register(OverrideStorage.self) { r in
-            BaseOverrideStorage(resolver: r, context: self.testContext)
+            BaseOverrideStorage(resolver: r, contextProvider: { self.testContext })
         }.inObjectScope(.container)
     }
 }

+ 13 - 1
TrioTests/OpenAPSSwiftTests/ProfileCarbsTests.swift

@@ -35,11 +35,23 @@ import Testing
         #expect(ratio == 1) // 12 grams per exchange
     }
 
+    @Test("should allow ratios of 1") func allowLowRatios() async throws {
+        let schedule = CarbRatios(
+            units: .grams,
+            schedule: [
+                CarbRatioEntry(start: "00:00:00", offset: 0, ratio: 1)
+            ]
+        )
+        let now = Calendar.current.date(from: DateComponents(year: 2025, month: 1, day: 26, hour: 2))!
+        let ratio = Carbs.carbRatioLookup(carbRatio: schedule, now: now)
+        #expect(ratio == 1)
+    }
+
     @Test("should reject invalid ratios") func rejectInvalidRatios() async throws {
         let invalidSchedule = CarbRatios(
             units: .grams,
             schedule: [
-                CarbRatioEntry(start: "00:00:00", offset: 0, ratio: 2),
+                CarbRatioEntry(start: "00:00:00", offset: 0, ratio: 0.5),
                 CarbRatioEntry(start: "03:00:00", offset: 180, ratio: 15)
             ]
         )