Jelajahi Sumber

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

Deniz Cengiz 2 hari lalu
induk
melakukan
d456d69b5c
88 mengubah file dengan 29560 tambahan dan 2500 penghapusan
  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"]
 [submodule "OmnipodKit"]
 	path = OmnipodKit
 	path = OmnipodKit
 	url = https://github.com/loopandlearn/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
 // The developers set the version numbers, please leave them alone
 APP_VERSION = 0.8.4
 APP_VERSION = 0.8.4
-APP_DEV_VERSION = 0.8.4.7
+APP_DEV_VERSION = 0.8.4.15
 APP_BUILD_NUMBER = 1
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 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),
             tempTargetDate: Date().addingTimeInterval(-1800),
             tempTargetDuration: 60,
             tempTargetDuration: 60,
             tempTargetTarget: 120,
             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.
     // 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 SwiftUI
 import WidgetKit
 import WidgetKit
 
 
+private enum ForecastDisplayTypeKey {
+    static let lines = "lines"
+    static let cone = "cone"
+}
+
 struct LiveActivityChartView: View {
 struct LiveActivityChartView: View {
     @Environment(\.colorScheme) var colorScheme
     @Environment(\.colorScheme) var colorScheme
     @Environment(\.isWatchOS) var isWatchOS
     @Environment(\.isWatchOS) var isWatchOS
@@ -22,9 +27,13 @@ struct LiveActivityChartView: View {
 
 
         let maxThreshhold: Decimal = isWatchOS ? 220 : 300
         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
         let yAxisRuleMarkMin = isMgdL ? state.lowGlucose : state.lowGlucose
             .asMmolL
             .asMmolL
@@ -34,12 +43,22 @@ struct LiveActivityChartView: View {
 
 
         let isOverrideActive = additionalState.isOverrideActive == true
         let isOverrideActive = additionalState.isOverrideActive == true
         let isTempTargetActive = additionalState.isTempTargetActive == true
         let isTempTargetActive = additionalState.isTempTargetActive == true
+        let hasForecast = !additionalState.minForecast.isEmpty || !additionalState.forecastLines.isEmpty
 
 
         let calendar = Calendar.current
         let calendar = Calendar.current
         let now = Date()
         let now = Date()
 
 
         let startDate = calendar.date(byAdding: .hour, value: isWatchOS ? -3 : -6, to: now) ?? now
         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
         // 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
         let hardCodedLow = isMgdL ? Decimal(55) : 55.asMmolL
@@ -83,6 +102,14 @@ struct LiveActivityChartView: View {
                 drawActiveTempTarget()
                 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)
             drawChart(yAxisRuleMarkMin: yAxisRuleMarkMin, yAxisRuleMarkMax: yAxisRuleMarkMax)
         }
         }
         .chartYAxis {
         .chartYAxis {
@@ -149,6 +176,72 @@ struct LiveActivityChartView: View {
         .lineStyle(.init(lineWidth: 8))
         .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 {
     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
         // 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)
         let hardCodedLow = Decimal(55)

+ 6 - 82
Model/CoreDataObserver.swift

@@ -2,79 +2,20 @@ import Combine
 import CoreData
 import CoreData
 import Foundation
 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> {
 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:
     /// Example usage:
     /// ```swift
     /// ```swift
     /// // Filter changes for "GlucoseStored" entity
     /// // 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
     /// - Returns: A publisher emitting filtered sets of NSManagedObjectIDs
     func filteredByEntityName(
     func filteredByEntityName(
         _ name: String
         _ 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 CoreData
 import Foundation
 import Foundation
 import OSLog
 import OSLog
@@ -9,11 +10,26 @@ class CoreDataStack: ObservableObject {
     private var notificationToken: NSObjectProtocol?
     private var notificationToken: NSObjectProtocol?
     private let inMemory: Bool
     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
     let persistentContainer: NSPersistentContainer
 
 
     private let maxRetries = 3
     private let maxRetries = 3
     private let initializationCoordinator = CoreDataInitializationCoordinator()
     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) {
     private init(inMemory: Bool = false) {
         self.inMemory = inMemory
         self.inMemory = inMemory
 
 
@@ -103,8 +119,6 @@ class CoreDataStack: ObservableObject {
         /// - Tag: newBackgroundContext
         /// - Tag: newBackgroundContext
         let taskContext = persistentContainer.newBackgroundContext()
         let taskContext = persistentContainer.newBackgroundContext()
 
 
-        /// ensure that the background contexts stay in sync with the main context
-        taskContext.automaticallyMergesChangesFromParent = true
         taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
         taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
         taskContext.undoManager = nil
         taskContext.undoManager = nil
         return taskContext
         return taskContext
@@ -160,11 +174,19 @@ class CoreDataStack: ObservableObject {
         // Update view context with objectIDs from history change request
         // Update view context with objectIDs from history change request
         /// - Tag: mergeChanges
         /// - Tag: mergeChanges
         let viewContext = persistentContainer.viewContext
         let viewContext = persistentContainer.viewContext
+        let changedObjectIDs = Set(history.flatMap { $0.changes ?? [] }.map(\.changedObjectID))
+
         viewContext.perform {
         viewContext.perform {
             for transaction in history {
             for transaction in history {
                 viewContext.mergeChanges(fromContextDidSave: transaction.objectIDNotification())
                 viewContext.mergeChanges(fromContextDidSave: transaction.objectIDNotification())
                 self.lastToken = transaction.token
                 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,
             forName: .NSPersistentStoreRemoteChange,
             object: nil,
             object: nil,
             queue: 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")
         debug(.coreData, "Set up persistent store change notifications")
     }
     }
 
 
@@ -320,7 +349,9 @@ extension CoreDataStack {
         taskContext.transactionAuthor = "batchDelete"
         taskContext.transactionAuthor = "batchDelete"
 
 
         // Get the number of days we want to keep the data
         // 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
         // Fetch all the objects that are older than the specified days
         let fetchRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: objectType))
         let fetchRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: objectType))
@@ -378,7 +409,9 @@ extension CoreDataStack {
         taskContext.transactionAuthor = "batchDelete"
         taskContext.transactionAuthor = "batchDelete"
 
 
         // Get the target date
         // 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
         // Fetch Parent objects older than the target date
         let fetchParentRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: parentType))
         let fetchParentRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: parentType))
@@ -465,9 +498,6 @@ extension CoreDataStack {
             request.resultType = .managedObjectResultType
             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
         /// 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 {
         return try context.performAndWait {
             do {
             do {
@@ -519,9 +549,6 @@ extension CoreDataStack {
             request.relationshipKeyPathsForPrefetching = prefetchKeyPaths
             request.relationshipKeyPathsForPrefetching = prefetchKeyPaths
         }
         }
 
 
-        context.name = "fetchContext"
-        context.transactionAuthor = "fetchEntities"
-
         return try await context.perform {
         return try await context.perform {
             do {
             do {
                 if propertiesToFetch != nil {
                 if propertiesToFetch != nil {

+ 6 - 0
Model/Helper/NSPredicates.swift

@@ -66,6 +66,12 @@ extension NSPredicate {
         return NSPredicate(format: "date >= %@", date as NSDate)
         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 {
     static var carbsHistory: NSPredicate {
         let date = Date.oneDayAgo
         let date = Date.oneDayAgo
         return NSPredicate(format: "date >= %@ AND carbs > 0", date as NSDate)
         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"?>
 <?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">
     <entity name="BolusStored" representedClassName="BolusStored" syncable="YES">
         <attribute name="amount" optional="YES" attributeType="Decimal" defaultValueString="0"/>
         <attribute name="amount" optional="YES" attributeType="Decimal" defaultValueString="0"/>
         <attribute name="isExternal" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
         <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 */; };
 		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, ); }; };
 		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 */; };
 		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 */; };
 		3EF667132FE48509009FB31A /* BasalDeliveryState+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF667122FE48502009FB31A /* BasalDeliveryState+Extension.swift */; };
 		3F23E18680094E6DA98628E4 /* QuickPickBolusesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A54068ABDAE4898B243DF14 /* QuickPickBolusesView.swift */; };
 		3F23E18680094E6DA98628E4 /* QuickPickBolusesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A54068ABDAE4898B243DF14 /* QuickPickBolusesView.swift */; };
 		41740E936552456AAC0EDAC3 /* SettingsSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3919BBB515547118D684CA2 /* SettingsSearchTests.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 */; };
 		BD04ECCE2D29952A008C5FEB /* BolusProgressOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD04ECCD2D299522008C5FEB /* BolusProgressOverlay.swift */; };
 		BD0B2EF32C5998E600B3298F /* MealPresetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0B2EF22C5998E600B3298F /* MealPresetView.swift */; };
 		BD0B2EF32C5998E600B3298F /* MealPresetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0B2EF22C5998E600B3298F /* MealPresetView.swift */; };
 		BD10516D2DA986E1007C6D89 /* PulsingLogoAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD10516C2DA986DC007C6D89 /* PulsingLogoAnimation.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 */; };
 		BD1179202F4E22C100F90001 /* TrioAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179212F4E22C100F90001 /* TrioAlertManager.swift */; };
 		BD1179222F4E22C100F90001 /* TrioModalAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */; };
 		BD1179222F4E22C100F90001 /* TrioModalAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */; };
 		BD1179242F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */; };
 		BD1179242F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */; };
@@ -976,6 +980,7 @@
 				CE95BF642BA771BE00DC3DE3 /* LoopTestingKit.framework in Embed Frameworks */,
 				CE95BF642BA771BE00DC3DE3 /* LoopTestingKit.framework in Embed Frameworks */,
 				CE95BF622BA7715900DC3DE3 /* MockKitUI.framework in Embed Frameworks */,
 				CE95BF622BA7715900DC3DE3 /* MockKitUI.framework in Embed Frameworks */,
 				3B4BA78D2D8DC0EC0069D5B8 /* ShareClientUI.framework in Embed Frameworks */,
 				3B4BA78D2D8DC0EC0069D5B8 /* ShareClientUI.framework in Embed Frameworks */,
+				3E84DA412F48D96000033608 /* EversenseKit.framework in Embed Frameworks */,
 				3B4BA7872D8DBD690069D5B8 /* RileyLinkKitUI.framework in Embed Frameworks */,
 				3B4BA7872D8DBD690069D5B8 /* RileyLinkKitUI.framework in Embed Frameworks */,
 				3B4BA78B2D8DC0EC0069D5B8 /* ShareClient.framework in Embed Frameworks */,
 				3B4BA78B2D8DC0EC0069D5B8 /* ShareClient.framework in Embed Frameworks */,
 				3B4BA7912D8DC0EC0069D5B8 /* TidepoolServiceKitUI.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>"; };
 		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; };
 		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>"; };
 		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>"; };
 		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>"; };
 		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>"; };
 		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>"; };
 		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>"; };
 		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>"; };
 		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>"; };
 		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>"; };
 		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>"; };
 		BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioUserNotificationAlertScheduler.swift; sourceTree = "<group>"; };
@@ -1948,6 +1956,7 @@
 				B958F1B72BA0711600484851 /* MKRingProgressView in Frameworks */,
 				B958F1B72BA0711600484851 /* MKRingProgressView in Frameworks */,
 				3B4BA7702D8DBD690069D5B8 /* G7SensorKit.framework in Frameworks */,
 				3B4BA7702D8DBD690069D5B8 /* G7SensorKit.framework in Frameworks */,
 				3B4BA76C2D8DBD690069D5B8 /* CGMBLEKitUI.framework in Frameworks */,
 				3B4BA76C2D8DBD690069D5B8 /* CGMBLEKitUI.framework in Frameworks */,
+				3E84DA402F48D96000033608 /* EversenseKit.framework in Frameworks */,
 				CE95BF5B2BA770C300DC3DE3 /* LoopKit.framework in Frameworks */,
 				CE95BF5B2BA770C300DC3DE3 /* LoopKit.framework in Frameworks */,
 				38B17B6625DD90E0005CAE3D /* SwiftDate in Frameworks */,
 				38B17B6625DD90E0005CAE3D /* SwiftDate in Frameworks */,
 				3833B46D26012030003021B3 /* Algorithms in Frameworks */,
 				3833B46D26012030003021B3 /* Algorithms in Frameworks */,
@@ -2556,6 +2565,7 @@
 			children = (
 			children = (
 				B6E925122EB3932A0076D719 /* OmnipodKit.framework */,
 				B6E925122EB3932A0076D719 /* OmnipodKit.framework */,
 				3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */,
 				3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */,
+				3E84DA3F2F48D96000033608 /* EversenseKit.framework */,
 				3B4BA7882D8DC0EC0069D5B8 /* TidepoolServiceKit.framework */,
 				3B4BA7882D8DC0EC0069D5B8 /* TidepoolServiceKit.framework */,
 				3B4BA7892D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework */,
 				3B4BA7892D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework */,
 				3B4BA75B2D8DBD690069D5B8 /* CGMBLEKit.framework */,
 				3B4BA75B2D8DBD690069D5B8 /* CGMBLEKit.framework */,
@@ -2738,6 +2748,7 @@
 				388358C725EEF6D200E024B2 /* BasalProfileEntry.swift */,
 				388358C725EEF6D200E024B2 /* BasalProfileEntry.swift */,
 				38D0B3B525EBE24900CB6E88 /* Battery.swift */,
 				38D0B3B525EBE24900CB6E88 /* Battery.swift */,
 				382C134A25F14E3700715CE1 /* BGTargets.swift */,
 				382C134A25F14E3700715CE1 /* BGTargets.swift */,
+				3E705AFA2FF94FAB0007F96B /* BolusStatus.swift */,
 				3870FF4225EC13F40088248F /* BloodGlucose.swift */,
 				3870FF4225EC13F40088248F /* BloodGlucose.swift */,
 				38A9260425F012D8009E3739 /* CarbRatios.swift */,
 				38A9260425F012D8009E3739 /* CarbRatios.swift */,
 				38D0B3D825EC07C400CB6E88 /* CarbsEntry.swift */,
 				38D0B3D825EC07C400CB6E88 /* CarbsEntry.swift */,
@@ -3275,6 +3286,7 @@
 		587A54C82BCDCE0F009D38E2 /* Model */ = {
 		587A54C82BCDCE0F009D38E2 /* Model */ = {
 			isa = PBXGroup;
 			isa = PBXGroup;
 			children = (
 			children = (
+				BD136FAD2F6AC555002217F9 /* NSFetchedResultsControllerDelegate.swift */,
 				3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */,
 				3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */,
 				BDF34F8F2C10CF8C00D51995 /* CoreDataStack.swift */,
 				BDF34F8F2C10CF8C00D51995 /* CoreDataStack.swift */,
 				BD4064D02C4ED26900582F43 /* CoreDataObserver.swift */,
 				BD4064D02C4ED26900582F43 /* CoreDataObserver.swift */,
@@ -5510,6 +5522,7 @@
 				BDA7593E2D37CFC400E649A4 /* CarbEntryEditorView.swift in Sources */,
 				BDA7593E2D37CFC400E649A4 /* CarbEntryEditorView.swift in Sources */,
 				118DF76A2C5ECBC60067FEB7 /* ApplyOverridePresetIntent.swift in Sources */,
 				118DF76A2C5ECBC60067FEB7 /* ApplyOverridePresetIntent.swift in Sources */,
 				58645B992CA2D1A4008AFCE7 /* GlucoseSetup.swift in Sources */,
 				58645B992CA2D1A4008AFCE7 /* GlucoseSetup.swift in Sources */,
+				BD136FAE2F6AC55F002217F9 /* NSFetchedResultsControllerDelegate.swift in Sources */,
 				7BCFACB97C821041BA43A114 /* ManualTempBasalRootView.swift in Sources */,
 				7BCFACB97C821041BA43A114 /* ManualTempBasalRootView.swift in Sources */,
 				38E44534274E411700EC9A94 /* Disk+InternalHelpers.swift in Sources */,
 				38E44534274E411700EC9A94 /* Disk+InternalHelpers.swift in Sources */,
 				DDF691052DA2CA23008BF16C /* AppDiagnosticsStateModel.swift in Sources */,
 				DDF691052DA2CA23008BF16C /* AppDiagnosticsStateModel.swift in Sources */,
@@ -5574,6 +5587,7 @@
 				DDE1795E2C910127003CDDB7 /* PumpEventStored+CoreDataClass.swift in Sources */,
 				DDE1795E2C910127003CDDB7 /* PumpEventStored+CoreDataClass.swift in Sources */,
 				3BBB76AA2E01C70B0040977D /* MealCob.swift in Sources */,
 				3BBB76AA2E01C70B0040977D /* MealCob.swift in Sources */,
 				BDDAF9EF2D00554500B34E7A /* SelectionPopoverView.swift in Sources */,
 				BDDAF9EF2D00554500B34E7A /* SelectionPopoverView.swift in Sources */,
+				3E705AFB2FF94FAB0007F96B /* BolusStatus.swift in Sources */,
 				DDE1795F2C910127003CDDB7 /* PumpEventStored+CoreDataProperties.swift in Sources */,
 				DDE1795F2C910127003CDDB7 /* PumpEventStored+CoreDataProperties.swift in Sources */,
 				DDE179602C910127003CDDB7 /* StatsData+CoreDataClass.swift in Sources */,
 				DDE179602C910127003CDDB7 /* StatsData+CoreDataClass.swift in Sources */,
 				DD30BA0E2E07700000DA677C /* Profile+Autosens.swift in Sources */,
 				DD30BA0E2E07700000DA677C /* Profile+Autosens.swift in Sources */,

+ 3 - 0
Trio.xcworkspace/contents.xcworkspacedata

@@ -35,6 +35,9 @@
       location = "group:G7SensorKit/G7SensorKit.xcodeproj">
       location = "group:G7SensorKit/G7SensorKit.xcodeproj">
    </FileRef>
    </FileRef>
    <FileRef
    <FileRef
+      location = "group:EversenseKit/EversenseKit.xcodeproj">
+   </FileRef>
+   <FileRef
       location = "group:TidepoolService/TidepoolService.xcodeproj">
       location = "group:TidepoolService/TidepoolService.xcodeproj">
    </FileRef>
    </FileRef>
 </Workspace>
 </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 openAPS: OpenAPS!
 
 
     private var lifetime = Lifetime()
     private var lifetime = Lifetime()
@@ -213,10 +211,12 @@ final class BaseAPSManager: APSManager, Injectable {
 
 
         deviceDataManager.bolusTrigger
         deviceDataManager.bolusTrigger
             .receive(on: processQueue)
             .receive(on: processQueue)
-            .sink { [weak self] bolusing in
-                if bolusing {
+            .sink { [weak self] bolusState in
+                switch bolusState {
+                case .initiating,
+                     .inProgress:
                     self?.createBolusReporter()
                     self?.createBolusReporter()
-                } else {
+                case .noBolus:
                     self?.clearBolusReporter()
                     self?.clearBolusReporter()
                 }
                 }
             }
             }
@@ -360,14 +360,16 @@ final class BaseAPSManager: APSManager, Injectable {
     }
     }
 
 
     private func calculateLoopInterval(loopStartDate: Date) async -> Double? {
     private func calculateLoopInterval(loopStartDate: Date) async -> Double? {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "calculateLoopInterval"
         do {
         do {
-            return try await privateContext.perform { [weak self] in
+            return try await context.perform { [weak self] in
                 guard let self else { return nil }
                 guard let self else { return nil }
                 let requestStats = LoopStatRecord.fetchRequest() as NSFetchRequest<LoopStatRecord>
                 let requestStats = LoopStatRecord.fetchRequest() as NSFetchRequest<LoopStatRecord>
                 let sortStats = NSSortDescriptor(key: "end", ascending: false)
                 let sortStats = NSSortDescriptor(key: "end", ascending: false)
                 requestStats.sortDescriptors = [sortStats]
                 requestStats.sortDescriptors = [sortStats]
                 requestStats.fetchLimit = 1
                 requestStats.fetchLimit = 1
-                let previousLoop = try self.privateContext.fetch(requestStats)
+                let previousLoop = try context.fetch(requestStats)
 
 
                 if (previousLoop.first?.end ?? .distantFuture) < loopStartDate {
                 if (previousLoop.first?.end ?? .distantFuture) < loopStartDate {
                     return self.roundDouble(
                     return self.roundDouble(
@@ -468,15 +470,27 @@ final class BaseAPSManager: APSManager, Injectable {
 
 
         try await calculateAndStoreTDD()
         try await calculateAndStoreTDD()
 
 
-        // Fetch glucose asynchronously
-        let glucose = try await fetchGlucose(predicate: NSPredicate.predicateForOneHourAgo, fetchLimit: 6)
-
         var invalidGlucoseError: String?
         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 }
             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 {
             guard glucose.count > 2 else {
                 debug(.apsManager, "Not enough glucose data")
                 debug(.apsManager, "Not enough glucose data")
                 invalidGlucoseError =
                 invalidGlucoseError =
@@ -697,16 +711,19 @@ final class BaseAPSManager: APSManager, Injectable {
     }
     }
 
 
     private func fetchCurrentTempBasal(date: Date) async throws -> TempBasal {
     private func fetchCurrentTempBasal(date: Date) async throws -> TempBasal {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchCurrentTempBasal"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             ofType: PumpEventStored.self,
-            onContext: privateContext,
+            onContext: context,
             predicate: NSPredicate.recentPumpHistory,
             predicate: NSPredicate.recentPumpHistory,
             key: "timestamp",
             key: "timestamp",
             ascending: false,
             ascending: false,
-            fetchLimit: 1
+            fetchLimit: 1,
+            relationshipKeyPathsForPrefetching: ["tempBasal"]
         )
         )
 
 
-        let fetchedTempBasal = await privateContext.perform {
+        let fetchedTempBasal = await context.perform {
             guard let fetchedResults = results as? [PumpEventStored],
             guard let fetchedResults = results as? [PumpEventStored],
                   let tempBasalEvent = fetchedResults.first,
                   let tempBasalEvent = fetchedResults.first,
                   let tempBasal = tempBasalEvent.tempBasal,
                   let tempBasal = tempBasalEvent.tempBasal,
@@ -771,9 +788,11 @@ final class BaseAPSManager: APSManager, Injectable {
     private func setValues(determinationID: NSManagedObjectID) async throws
     private func setValues(determinationID: NSManagedObjectID) async throws
         -> (NSDecimalNumber?, TimeInterval?, NSDecimalNumber?)
         -> (NSDecimalNumber?, TimeInterval?, NSDecimalNumber?)
     {
     {
-        return try await privateContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "setValues"
+        return try await context.perform {
             do {
             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 rate = determination?.rate
                 let duration = determination?.duration.flatMap { TimeInterval(truncating: $0) * 60 }
                 let duration = determination?.duration.flatMap { TimeInterval(truncating: $0) * 60 }
@@ -804,8 +823,10 @@ final class BaseAPSManager: APSManager, Injectable {
                 return
                 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
                     .existingObject(with: determinationID) as? OrefDetermination
                 else {
                 else {
                     debug(.apsManager, "Could not find determination object in context")
                     debug(.apsManager, "Could not find determination object in context")
@@ -816,8 +837,8 @@ final class BaseAPSManager: APSManager, Injectable {
                 determinationUpdated.enacted = wasEnacted
                 determinationUpdated.enacted = wasEnacted
                 determinationUpdated.isUploadedToNS = false
                 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)")
                 debug(.apsManager, "Determination enacted. Enacted: \(wasEnacted)")
             }
             }
         } catch {
         } catch {
@@ -864,8 +885,8 @@ final class BaseAPSManager: APSManager, Injectable {
         return Double(sorted[length / 2])
         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) {
     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 justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
         let totalReadings = justGlucoseArray.count
         let totalReadings = justGlucoseArray.count
@@ -983,25 +1004,26 @@ final class BaseAPSManager: APSManager, Injectable {
         return output
         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,
             ofType: GlucoseStored.self,
-            onContext: privateContext,
+            onContext: context,
             predicate: predicate,
             predicate: predicate,
             key: "date",
             key: "date",
             ascending: false,
             ascending: false,
             fetchLimit: fetchLimit,
             fetchLimit: fetchLimit,
             batchSize: batchSize
             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? {
     private func lastLoopForStats() async -> Date? {
@@ -1010,9 +1032,11 @@ final class BaseAPSManager: APSManager, Injectable {
         requestStats.sortDescriptors = [sortStats]
         requestStats.sortDescriptors = [sortStats]
         requestStats.fetchLimit = 1
         requestStats.fetchLimit = 1
 
 
-        return await privateContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "lastLoopForStats"
+        return await context.perform {
             do {
             do {
-                return try self.privateContext.fetch(requestStats).first?.lastrun
+                return try context.fetch(requestStats).first?.lastrun
             } catch {
             } catch {
                 print(error.localizedDescription)
                 print(error.localizedDescription)
                 return .distantPast
                 return .distantPast
@@ -1029,9 +1053,11 @@ final class BaseAPSManager: APSManager, Injectable {
         let sortLSR = NSSortDescriptor(key: "start", ascending: false)
         let sortLSR = NSSortDescriptor(key: "start", ascending: false)
         requestLSR.sortDescriptors = [sortLSR]
         requestLSR.sortDescriptors = [sortLSR]
 
 
-        return await privateContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "loopStats"
+        return await context.perform {
             do {
             do {
-                let lsr = try self.privateContext.fetch(requestLSR)
+                let lsr = try context.fetch(requestLSR)
 
 
                 // Compute LoopStats for 24 hours
                 // Compute LoopStats for 24 hours
                 let oneDayLoops = self.loops(lsr)
                 let oneDayLoops = self.loops(lsr)
@@ -1081,23 +1107,36 @@ final class BaseAPSManager: APSManager, Injectable {
         hbs: Durations,
         hbs: Durations,
         variance: Variance
         variance: Variance
     )? {
     )? {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "glucoseForStats"
         do {
         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
                 let units = self.settingsManager.settings.units
 
 
                 // First date
                 // First date
@@ -1219,9 +1258,10 @@ final class BaseAPSManager: APSManager, Injectable {
     }
     }
 
 
     private func loopStats(loopStatRecord: LoopStats) {
     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.start = loopStatRecord.start
             nLS.end = loopStatRecord.end ?? Date()
             nLS.end = loopStatRecord.end ?? Date()
             nLS.loopStatus = loopStatRecord.loopStatus
             nLS.loopStatus = loopStatRecord.loopStatus
@@ -1229,8 +1269,8 @@ final class BaseAPSManager: APSManager, Injectable {
             nLS.interval = loopStatRecord.interval ?? 0.0
             nLS.interval = loopStatRecord.interval ?? 0.0
 
 
             do {
             do {
-                guard self.privateContext.hasChanges else { return }
-                try self.privateContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch {
             } catch {
                 print(error.localizedDescription)
                 print(error.localizedDescription)
             }
             }
@@ -1347,6 +1387,10 @@ final class BaseAPSManager: APSManager, Injectable {
     /// Mutations are dispatched onto the queue regardless, so a future
     /// Mutations are dispatched onto the queue regardless, so a future
     /// caller from another context (e.g. `cancelBolus`) stays safe.
     /// caller from another context (e.g. `cancelBolus`) stays safe.
     private func createBolusReporter() {
     private func createBolusReporter() {
+        if bolusReporter != nil {
+            return
+        }
+
         processQueue.async {
         processQueue.async {
             self.bolusReporter = self.pumpManager?.createBolusProgressReporter(reportingOn: self.processQueue)
             self.bolusReporter = self.pumpManager?.createBolusProgressReporter(reportingOn: self.processQueue)
             self.bolusReporter?.addObserver(self)
             self.bolusReporter?.addObserver(self)
@@ -1437,8 +1481,9 @@ extension BaseAPSManager: PumpManagerStatusObserver {
     func pumpManager(_: PumpManager, didUpdate status: PumpManagerStatus, oldStatus _: PumpManagerStatus) {
     func pumpManager(_: PumpManager, didUpdate status: PumpManagerStatus, oldStatus _: PumpManagerStatus) {
         let percent = Int((status.pumpBatteryChargeRemaining ?? 1) * 100)
         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
             /// 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()
             let fetchRequest: NSFetchRequest<OpenAPS_Battery> = OpenAPS_Battery.fetchRequest()
             fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
             fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
@@ -1446,13 +1491,13 @@ extension BaseAPSManager: PumpManagerStatusObserver {
             fetchRequest.fetchLimit = 1
             fetchRequest.fetchLimit = 1
 
 
             do {
             do {
-                let results = try self.privateContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 let batteryToStore: OpenAPS_Battery
                 let batteryToStore: OpenAPS_Battery
 
 
                 if let existingBattery = results.first {
                 if let existingBattery = results.first {
                     batteryToStore = existingBattery
                     batteryToStore = existingBattery
                 } else {
                 } else {
-                    batteryToStore = OpenAPS_Battery(context: self.privateContext)
+                    batteryToStore = OpenAPS_Battery(context: context)
                     batteryToStore.id = UUID()
                     batteryToStore.id = UUID()
                 }
                 }
 
 
@@ -1462,8 +1507,8 @@ extension BaseAPSManager: PumpManagerStatusObserver {
                 batteryToStore.status = percent > 10 ? "normal" : "low"
                 batteryToStore.status = percent > 10 ? "normal" : "low"
                 batteryToStore.display = status.pumpBatteryChargeRemaining != nil
                 batteryToStore.display = status.pumpBatteryChargeRemaining != nil
 
 
-                guard self.privateContext.hasChanges else { return }
-                try self.privateContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch {
             } catch {
                 debug(.apsManager, "Failed to fetch or save battery: \(error)")
                 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 loopInProgress: Bool { get set }
     var pumpDisplayState: CurrentValueSubject<PumpDisplayState?, Never> { get }
     var pumpDisplayState: CurrentValueSubject<PumpDisplayState?, Never> { get }
     var recommendsLoop: PassthroughSubject<Void, 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 manualTempBasal: PassthroughSubject<Bool, Never> { get }
     var scheduledBasal: PassthroughSubject<Bool?, Never> { get }
     var scheduledBasal: PassthroughSubject<Bool?, Never> { get }
     var suspended: PassthroughSubject<Bool, Never> { get }
     var suspended: PassthroughSubject<Bool, Never> { get }
@@ -69,7 +69,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
         .distantPast
         .distantPast
 
 
     let recommendsLoop = PassthroughSubject<Void, Never>()
     let recommendsLoop = PassthroughSubject<Void, Never>()
-    let bolusTrigger = PassthroughSubject<Bool, Never>()
+    let bolusTrigger = CurrentValueSubject<BolusStatus, Never>(.noBolus)
     let errorSubject = PassthroughSubject<Error, Never>()
     let errorSubject = PassthroughSubject<Error, Never>()
     let pumpNewStatus = PassthroughSubject<Void, Never>()
     let pumpNewStatus = PassthroughSubject<Void, Never>()
     let manualTempBasal = PassthroughSubject<Bool, Never>()
     let manualTempBasal = PassthroughSubject<Bool, Never>()
@@ -80,7 +80,6 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
     @SyncAccess private var pumpUpdateCancellable: AnyCancellable?
     @SyncAccess private var pumpUpdateCancellable: AnyCancellable?
     private var pumpUpdatePromise: Future<Bool, Never>.Promise?
     private var pumpUpdatePromise: Future<Bool, Never>.Promise?
     @SyncAccess var loopInProgress: Bool = false
     @SyncAccess var loopInProgress: Bool = false
-    private let privateContext = CoreDataStack.shared.newTaskContext()
 
 
     var pumpManager: PumpManagerUI? {
     var pumpManager: PumpManagerUI? {
         didSet {
         didSet {
@@ -174,8 +173,10 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
                         display: simulatorPump.state.pumpBatteryChargeRemaining != nil
                         display: simulatorPump.state.pumpBatteryChargeRemaining != nil
                     )
                     )
                     Task {
                     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.id = UUID()
                             saveBatteryToCoreData.date = Date()
                             saveBatteryToCoreData.date = Date()
                             saveBatteryToCoreData.percent = Double(batteryPercent)
                             saveBatteryToCoreData.percent = Double(batteryPercent)
@@ -185,8 +186,8 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
                             saveBatteryToCoreData.display = simulatorPump.state.pumpBatteryChargeRemaining != nil
                             saveBatteryToCoreData.display = simulatorPump.state.pumpBatteryChargeRemaining != nil
 
 
                             do {
                             do {
-                                guard self.privateContext.hasChanges else { return }
-                                try self.privateContext.save()
+                                guard context.hasChanges else { return }
+                                try context.save()
                             } catch {
                             } catch {
                                 print(error.localizedDescription)
                                 print(error.localizedDescription)
                             }
                             }
@@ -209,18 +210,20 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
                 storage.save(modifiedPreferences, as: OpenAPS.Settings.preferences)
                 storage.save(modifiedPreferences, as: OpenAPS.Settings.preferences)
                 // Remove OpenAPS_Battery entries
                 // Remove OpenAPS_Battery entries
                 Task {
                 Task {
-                    await self.privateContext.perform {
+                    let context = CoreDataStack.shared.newTaskContext()
+                    context.name = "deleteBatteryEntries"
+                    await context.perform {
                         let fetchRequest: NSFetchRequest<OpenAPS_Battery> = OpenAPS_Battery.fetchRequest()
                         let fetchRequest: NSFetchRequest<OpenAPS_Battery> = OpenAPS_Battery.fetchRequest()
 
 
                         do {
                         do {
-                            let batteryEntries = try self.privateContext.fetch(fetchRequest)
+                            let batteryEntries = try context.fetch(fetchRequest)
 
 
                             for entry in batteryEntries {
                             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 {
                         } catch {
                             debug(.deviceManager, "Failed to delete OpenAPS_Battery entries: \(error)")
                             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 Bolus: \(status.bolusState)")
         debug(.deviceManager, "New pump status Basal: \(String(describing: status.basalDeliveryState))")
         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 {
         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 lazy var simulatorSource = GlucoseSimulatorSource()
 
 
-    private let context = CoreDataStack.shared.newTaskContext()
-
     /// Enforce mutual exclusion on calls to glucoseStoreAndHeartDecision
     /// Enforce mutual exclusion on calls to glucoseStoreAndHeartDecision
     private let glucoseStoreAndHeartLock = DispatchSemaphore(value: 1)
     private let glucoseStoreAndHeartLock = DispatchSemaphore(value: 1)
 
 
@@ -305,7 +303,9 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         try await glucoseStorage.storeGlucose(filtered)
         try await glucoseStorage.storeGlucose(filtered)
 
 
         if settingsManager.settings.smoothGlucose {
         if settingsManager.settings.smoothGlucose {
-            await exponentialSmoothingGlucose(context: context)
+            let smoothingContext = CoreDataStack.shared.newTaskContext()
+            smoothingContext.name = "exponentialSmoothingGlucose"
+            await exponentialSmoothingGlucose(context: smoothingContext)
         }
         }
 
 
         deviceDataManager.heartbeat(date: Date())
         deviceDataManager.heartbeat(date: Date())
@@ -382,7 +382,9 @@ extension BaseFetchGlucoseManager: SettingsObserver {
 
 
             self.glucoseStoreAndHeartLock.wait()
             self.glucoseStoreAndHeartLock.wait()
             Task {
             Task {
-                await self.exponentialSmoothingGlucose(context: self.context)
+                let context = CoreDataStack.shared.newTaskContext()
+                context.name = "exponentialSmoothingGlucose"
+                await self.exponentialSmoothingGlucose(context: context)
                 self.glucoseStoreAndHeartLock.signal()
                 self.glucoseStoreAndHeartLock.signal()
             }
             }
         }
         }

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

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

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

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

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

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

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

@@ -1,4 +1,5 @@
 import CGMBLEKit
 import CGMBLEKit
+import EversenseKit
 import Foundation
 import Foundation
 import G7SensorKit
 import G7SensorKit
 import G7SensorKitUI
 import G7SensorKitUI
@@ -40,6 +41,11 @@ class BasePluginManager: Injectable, PluginManager {
             pluginIdentifier: LibreTransmitterManagerV3.pluginIdentifier,
             pluginIdentifier: LibreTransmitterManagerV3.pluginIdentifier,
             localizedTitle: String(localized: "FreeStyle Libre"),
             localizedTitle: String(localized: "FreeStyle Libre"),
             manager: LibreTransmitterManagerV3.self
             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()
         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)
         injectServices(resolver)
     }
     }
 
 
@@ -74,6 +74,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
     }
 
 
     private func filterRemoteEntries(entries: [CarbsEntry]) async throws -> [CarbsEntry] {
     private func filterRemoteEntries(entries: [CarbsEntry]) async throws -> [CarbsEntry] {
+        let context = makeContext()
+        context.name = "filterRemoteEntries"
         // Fetch only the date property from Core Data
         // Fetch only the date property from Core Data
         guard let existing24hCarbEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
         guard let existing24hCarbEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             ofType: CarbEntryStored.self,
@@ -280,8 +282,10 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     private func saveCarbsToCoreData(entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
     private func saveCarbsToCoreData(entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
         guard let entry = entries.last else { return }
         guard let entry = entries.last else { return }
 
 
+        let context = makeContext()
+        context.name = "saveCarbsToCoreData"
         await context.perform {
         await context.perform {
-            let newItem = CarbEntryStored(context: self.context)
+            let newItem = CarbEntryStored(context: context)
             newItem.date = entry.actualDate ?? entry.createdAt
             newItem.date = entry.actualDate ?? entry.createdAt
             newItem.carbs = Double(truncating: NSDecimalNumber(decimal: entry.carbs))
             newItem.carbs = Double(truncating: NSDecimalNumber(decimal: entry.carbs))
             newItem.fat = Double(truncating: NSDecimalNumber(decimal: entry.fat ?? 0))
             newItem.fat = Double(truncating: NSDecimalNumber(decimal: entry.fat ?? 0))
@@ -298,8 +302,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             }
             }
 
 
             do {
             do {
-                guard self.context.hasChanges else { return }
-                try self.context.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch {
             } catch {
                 print(error.localizedDescription)
                 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
             // do NOT set Health and Tidepool flags to ensure they will NOT be uploaded
             return false // return false to continue
             return false // return false to continue
         }
         }
+        let context = makeContext()
+        context.name = "saveFPUToCoreDataAsBatchInsert"
         await context.perform {
         await context.perform {
             do {
             do {
-                try self.context.execute(batchInsert)
+                try context.execute(batchInsert)
                 debugPrint("Carbs Storage: \(DebuggingIdentifiers.succeeded) saved fpus to core data")
                 debugPrint("Carbs Storage: \(DebuggingIdentifiers.succeeded) saved fpus to core data")
 
 
                 // Notify subscriber in Home State Model to update the FPU Array
                 // 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 {
     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?
         var carbEntryFromCoreData: CarbEntryStored?
 
 
-        await taskContext.perform {
+        await context.perform {
             do {
             do {
-                carbEntryFromCoreData = try taskContext.existingObject(with: treatmentObjectID) as? CarbEntryStored
+                carbEntryFromCoreData = try context.existingObject(with: treatmentObjectID) as? CarbEntryStored
                 guard let carbEntry = carbEntryFromCoreData else {
                 guard let carbEntry = carbEntryFromCoreData else {
                     debugPrint("Carb entry for batch delete not found. \(DebuggingIdentifiers.failed)")
                     debugPrint("Carb entry for batch delete not found. \(DebuggingIdentifiers.failed)")
                     return
                     return
@@ -377,7 +378,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
                     deleteRequest.resultType = .resultTypeCount
                     deleteRequest.resultType = .resultTypeCount
 
 
                     // execute the batch delete request
                     // 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)")
                     debugPrint("\(DebuggingIdentifiers.succeeded) Deleted \(result?.result ?? 0) items with FpuID \(fpuID)")
 
 
                     // Notifiy subscribers of the batch delete
                     // Notifiy subscribers of the batch delete
@@ -386,10 +387,10 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
                 // entry has no fpuID
                 // entry has no fpuID
                 // => it's a carb-only entry. use its ID to for deletion
                 // => it's a carb-only entry. use its ID to for deletion
                 else {
                 else {
-                    taskContext.delete(carbEntry)
+                    context.delete(carbEntry)
 
 
-                    guard taskContext.hasChanges else { return }
-                    try taskContext.save()
+                    guard context.hasChanges else { return }
+                    try context.save()
 
 
                     debugPrint(
                     debugPrint(
                         "CarbsStorage: \(#function) \(DebuggingIdentifiers.succeeded) deleted carb entry from core data"
                         "CarbsStorage: \(#function) \(DebuggingIdentifiers.succeeded) deleted carb entry from core data"
@@ -403,6 +404,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
     }
 
 
     func getCarbsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
     func getCarbsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let context = makeContext()
+        context.name = "getCarbsNotYetUploadedToNightscout"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             ofType: CarbEntryStored.self,
             onContext: context,
             onContext: context,
@@ -442,6 +445,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
     }
 
 
     func getFPUsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
     func getFPUsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let context = makeContext()
+        context.name = "getFPUsNotYetUploadedToNightscout"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             ofType: CarbEntryStored.self,
             onContext: context,
             onContext: context,
@@ -481,6 +486,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
     }
 
 
     func getCarbsNotYetUploadedToHealth() async throws -> [CarbsEntry] {
     func getCarbsNotYetUploadedToHealth() async throws -> [CarbsEntry] {
+        let context = makeContext()
+        context.name = "getCarbsNotYetUploadedToHealth"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             ofType: CarbEntryStored.self,
             onContext: context,
             onContext: context,
@@ -512,6 +519,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
     }
 
 
     func getCarbsNotYetUploadedToTidepool() async throws -> [CarbsEntry] {
     func getCarbsNotYetUploadedToTidepool() async throws -> [CarbsEntry] {
+        let context = makeContext()
+        context.name = "getCarbsNotYetUploadedToTidepool"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             ofType: CarbEntryStored.self,
             onContext: context,
             onContext: context,

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

@@ -13,9 +13,10 @@ protocol ContactImageStorage {
 final class BaseContactImageStorage: ContactImageStorage, Injectable {
 final class BaseContactImageStorage: ContactImageStorage, Injectable {
     @Injected() private var settingsManager: SettingsManager!
     @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)
         injectServices(resolver)
     }
     }
 
 
@@ -26,16 +27,18 @@ final class BaseContactImageStorage: ContactImageStorage, Injectable {
     ///
     ///
     /// - Returns: An array of `ContactImageEntry` objects.
     /// - Returns: An array of `ContactImageEntry` objects.
     func fetchContactImageEntries() async -> [ContactImageEntry] {
     func fetchContactImageEntries() async -> [ContactImageEntry] {
+        let context = makeContext()
+        context.name = "fetchContactImageEntries"
         do {
         do {
             let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             let results = try await CoreDataStack.shared.fetchEntitiesAsync(
                 ofType: ContactImageEntryStored.self,
                 ofType: ContactImageEntryStored.self,
-                onContext: backgroundContext,
+                onContext: context,
                 predicate: NSPredicate.all,
                 predicate: NSPredicate.all,
                 key: "hasHighContrast",
                 key: "hasHighContrast",
                 ascending: false
                 ascending: false
             )
             )
 
 
-            return try await backgroundContext.perform {
+            return try await context.perform {
                 guard let fetchedContactImageEntries = results as? [ContactImageEntryStored]
                 guard let fetchedContactImageEntries = results as? [ContactImageEntryStored]
                 else { throw CoreDataError.fetchError(function: #function, file: #file)
                 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.
     /// - Parameter contactImageEntry: The `ContactImageEntry` object to be stored.
     func storeContactImageEntry(_ contactImageEntry: ContactImageEntry) async {
     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.id = UUID()
             newContactImageEntry.name = contactImageEntry.name
             newContactImageEntry.name = contactImageEntry.name
@@ -96,8 +101,8 @@ final class BaseContactImageStorage: ContactImageStorage, Injectable {
             newContactImageEntry.fontWeight = contactImageEntry.fontWeight.asString
             newContactImageEntry.fontWeight = contactImageEntry.fontWeight.asString
 
 
             do {
             do {
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
             } catch let error as NSError {
                 debugPrint(
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save Contact Trick Entry to Core Data with error: \(error.userInfo)"
                     "\(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.
     /// - Parameter contactImageEntry: The `ContactImageEntry` object with updated values.
     func updateContactImageEntry(_ contactImageEntry: ContactImageEntry) async {
     func updateContactImageEntry(_ contactImageEntry: ContactImageEntry) async {
-        await backgroundContext.perform {
+        let context = makeContext()
+        context.name = "updateContactImageEntry"
+        await context.perform {
             let fetchRequest: NSFetchRequest<ContactImageEntryStored> = ContactImageEntryStored.fetchRequest()
             let fetchRequest: NSFetchRequest<ContactImageEntryStored> = ContactImageEntryStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "contactId == %@", contactImageEntry.contactId ?? "")
             fetchRequest.predicate = NSPredicate(format: "contactId == %@", contactImageEntry.contactId ?? "")
 
 
             do {
             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
                     // Update the properties of the existing entry
                     existingEntry.name = contactImageEntry.name
                     existingEntry.name = contactImageEntry.name
                     existingEntry.layout = contactImageEntry.layout.rawValue
                     existingEntry.layout = contactImageEntry.layout.rawValue
@@ -136,8 +143,8 @@ final class BaseContactImageStorage: ContactImageStorage, Injectable {
                     existingEntry.fontWeight = contactImageEntry.fontWeight.asString
                     existingEntry.fontWeight = contactImageEntry.fontWeight.asString
                     existingEntry.fontWidth = contactImageEntry.fontWidth.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 {
                 } else {
                     debugPrint(
                     debugPrint(
                         "\(DebuggingIdentifiers.failed) \(#file) \(#function) No matching Contact Trick Entry found to update."
                         "\(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 {
 final class BaseDeterminationStorage: DeterminationStorage, Injectable {
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
     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)
         injectServices(resolver)
     }
     }
 
 
     func fetchLastDeterminationObjectID(predicate: NSPredicate) async throws -> [NSManagedObjectID] {
     func fetchLastDeterminationObjectID(predicate: NSPredicate) async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "fetchLastDeterminationObjectID"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
             ofType: OrefDetermination.self,
             onContext: context,
             onContext: context,
@@ -116,31 +118,30 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable {
 
 
     // Convert NSSet to array of Ints for Predictions
     // Convert NSSet to array of Ints for Predictions
     func parseForecastValues(ofType type: String, from determinationID: NSManagedObjectID) async -> [Int]? {
     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? {
     func getOrefDeterminationNotYetUploadedToNightscout(_ determinationIds: [NSManagedObjectID]) async -> Determination? {
@@ -157,9 +158,11 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable {
             uam: await parseForecastValues(ofType: "uam", from: determinationId)
             uam: await parseForecastValues(ofType: "uam", from: determinationId)
         )
         )
 
 
+        let context = makeContext()
+        context.name = "getOrefDeterminationNotYetUploadedToNightscout"
         return await context.perform {
         return await context.perform {
             do {
             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
                 // Check if the fetched object is of the expected type
                 if let orefDetermination = orefDetermination {
                 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
         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)
         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.
     ///  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.
     ///  But since this is a corner case that really shouldn't happen often, it's good enough.
     func backfillGlucose(_ glucose: [BloodGlucose]) async throws {
     func backfillGlucose(_ glucose: [BloodGlucose]) async throws {
+        let context = makeContext()
+        context.name = "backfillGlucose"
         let clamped = clampToMinimum(glucose)
         let clamped = clampToMinimum(glucose)
+
         try await context.perform {
         try await context.perform {
             // remove already deleted glucose values
             // remove already deleted glucose values
             let withoutDeletedGlucose = self.filterGlucoseValues(
             let withoutDeletedGlucose = self.filterGlucoseValues(
                 clamped,
                 clamped,
                 fetchRequest: DeletedGlucoseStored.fetchRequest(),
                 fetchRequest: DeletedGlucoseStored.fetchRequest(),
-                timeBuffer: 1
+                timeBuffer: 1,
+                context: context
             )
             )
 
 
             // check for a 3.5 minute difference between existing values
             // check for a 3.5 minute difference between existing values
             let filteredGlucose = self.filterGlucoseValues(
             let filteredGlucose = self.filterGlucoseValues(
                 withoutDeletedGlucose,
                 withoutDeletedGlucose,
                 fetchRequest: GlucoseStored.fetchRequest(),
                 fetchRequest: GlucoseStored.fetchRequest(),
-                timeBuffer: 3.5 * 60
+                timeBuffer: 3.5 * 60,
+                context: context
             )
             )
 
 
             guard !filteredGlucose.isEmpty else { return }
             guard !filteredGlucose.isEmpty else { return }
 
 
             do {
             do {
                 // Store glucose values in Core Data
                 // Store glucose values in Core Data
-                try self.storeGlucoseInCoreData(filteredGlucose)
+                try self.storeGlucoseInCoreData(filteredGlucose, context: context)
             } catch {
             } catch {
                 throw CoreDataError.creationError(
                 throw CoreDataError.creationError(
                     function: #function,
                     function: #function,
@@ -108,15 +113,23 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
     }
 
 
     func storeGlucose(_ glucose: [BloodGlucose]) async throws {
     func storeGlucose(_ glucose: [BloodGlucose]) async throws {
+        let context = makeContext()
+        context.name = "storeGlucose"
         let clamped = clampToMinimum(glucose)
         let clamped = clampToMinimum(glucose)
+
         try await context.perform {
         try await context.perform {
             // Get new glucose values that don't exist yet
             // 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 }
             guard !newGlucose.isEmpty else { return }
 
 
             do {
             do {
                 // Store glucose values in Core Data
                 // Store glucose values in Core Data
-                try self.storeGlucoseInCoreData(newGlucose)
+                try self.storeGlucoseInCoreData(newGlucose, context: context)
             } catch {
             } catch {
                 throw CoreDataError.creationError(
                 throw CoreDataError.creationError(
                     function: #function,
                     function: #function,
@@ -161,7 +174,8 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     private func filterGlucoseValues(
     private func filterGlucoseValues(
         _ glucose: [BloodGlucose],
         _ glucose: [BloodGlucose],
         fetchRequest: NSFetchRequest<NSFetchRequestResult>,
         fetchRequest: NSFetchRequest<NSFetchRequestResult>,
-        timeBuffer: TimeInterval
+        timeBuffer: TimeInterval,
+        context: NSManagedObjectContext
     ) -> [BloodGlucose] {
     ) -> [BloodGlucose] {
         let datesToCheck = glucose.map(\.dateString).sorted()
         let datesToCheck = glucose.map(\.dateString).sorted()
         guard let firstDate = datesToCheck.first.map({ $0.addingTimeInterval(-timeBuffer) }),
         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 {
         if glucose.count > 1 {
-            try storeGlucoseBatch(glucose)
+            try storeGlucoseBatch(glucose, context: context)
         } else {
         } 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 {
         for entry in glucose {
             let glucoseEntry = GlucoseStored(context: context)
             let glucoseEntry = GlucoseStored(context: context)
             configureGlucoseEntry(glucoseEntry, with: entry)
             configureGlucoseEntry(glucoseEntry, with: entry)
@@ -217,7 +231,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         updateSubject.send()
         updateSubject.send()
     }
     }
 
 
-    private func storeGlucoseBatch(_ glucose: [BloodGlucose]) throws {
+    private func storeGlucoseBatch(_ glucose: [BloodGlucose], context: NSManagedObjectContext) throws {
         var remainingGlucose = glucose
         var remainingGlucose = glucose
         let batchInsert = NSBatchInsertRequest(
         let batchInsert = NSBatchInsertRequest(
             entity: GlucoseStored.entity(),
             entity: GlucoseStored.entity(),
@@ -314,8 +328,11 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
     }
 
 
     func addManualGlucose(glucose: Int) {
     func addManualGlucose(glucose: Int) {
+        let context = makeContext()
+        context.name = "addManualGlucose"
+
         context.perform {
         context.perform {
-            let newItem = GlucoseStored(context: self.context)
+            let newItem = GlucoseStored(context: context)
             newItem.id = UUID()
             newItem.id = UUID()
             newItem.date = Date()
             newItem.date = Date()
             newItem.glucose = Int16(glucose)
             newItem.glucose = Int16(glucose)
@@ -325,8 +342,8 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
             newItem.isUploadedToTidepool = false
             newItem.isUploadedToTidepool = false
 
 
             do {
             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.
                 // Glucose subscribers already listen to the update publisher, so call here to update glucose-related data.
                 self.updateSubject.send()
                 self.updateSubject.send()
@@ -344,8 +361,10 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
     }
 
 
     func syncDate() -> Date {
     func syncDate() -> Date {
+        let context = makeContext()
+        context.name = "syncDate"
+
         // Optimize fetch request to only get the date
         // Optimize fetch request to only get the date
-        let taskContext = CoreDataStack.shared.newTaskContext()
         let fr = NSFetchRequest<NSDictionary>(entityName: "GlucoseStored")
         let fr = NSFetchRequest<NSDictionary>(entityName: "GlucoseStored")
         fr.predicate = NSPredicate.predicateForOneDayAgo
         fr.predicate = NSPredicate.predicateForOneDayAgo
         fr.propertiesToFetch = ["date"]
         fr.propertiesToFetch = ["date"]
@@ -355,9 +374,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
 
         var fetchedDate: Date = .distantPast
         var fetchedDate: Date = .distantPast
 
 
-        taskContext.performAndWait {
+        context.performAndWait {
             do {
             do {
-                if let result = try taskContext.fetch(fr).first,
+                if let result = try context.fetch(fr).first,
                    let date = result["date"] as? Date
                    let date = result["date"] as? Date
                 {
                 {
                     fetchedDate = date
                     fetchedDate = date
@@ -371,6 +390,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
     }
 
 
     func lastGlucoseDate() -> Date? {
     func lastGlucoseDate() -> Date? {
+        let context = makeContext()
+        context.name = "lastGlucoseDate"
+
         let fetchRequest = GlucoseStored.fetchRequest()
         let fetchRequest = GlucoseStored.fetchRequest()
         fetchRequest.predicate = NSPredicate.predicateForOneDayAgo
         fetchRequest.predicate = NSPredicate.predicateForOneDayAgo
         fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \GlucoseStored.date, ascending: false)]
         fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \GlucoseStored.date, ascending: false)]
@@ -379,7 +401,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         var date: Date?
         var date: Date?
         context.performAndWait {
         context.performAndWait {
             do {
             do {
-                let results = try self.context.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 date = results.first?.date
                 date = results.first?.date
             } catch let error as NSError {
             } catch let error as NSError {
                 debug(.storage, "Fetch error: \(DebuggingIdentifiers.failed) \(error), \(error.userInfo)")
                 debug(.storage, "Fetch error: \(DebuggingIdentifiers.failed) \(error), \(error.userInfo)")
@@ -409,7 +431,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         return filtered
         return filtered
     }
     }
 
 
-    func fetchLatestGlucose() throws -> GlucoseStored? {
+    func fetchLatestGlucose(context: NSManagedObjectContext) throws -> GlucoseStored? {
         let predicate = NSPredicate.predicateFor20MinAgo
         let predicate = NSPredicate.predicateFor20MinAgo
         return (try CoreDataStack.shared.fetchEntities(
         return (try CoreDataStack.shared.fetchEntities(
             ofType: GlucoseStored.self,
             ofType: GlucoseStored.self,
@@ -424,6 +446,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     // Fetch glucose that is not uploaded to Nightscout yet
     // Fetch glucose that is not uploaded to Nightscout yet
     /// - Returns: Array of BloodGlucose to ensure the correct format for the NS Upload
     /// - Returns: Array of BloodGlucose to ensure the correct format for the NS Upload
     func getGlucoseNotYetUploadedToNightscout() async throws -> [BloodGlucose] {
     func getGlucoseNotYetUploadedToNightscout() async throws -> [BloodGlucose] {
+        let context = makeContext()
+        context.name = "getGlucoseNotYetUploadedToNightscout"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             ofType: GlucoseStored.self,
             onContext: context,
             onContext: context,
@@ -477,6 +502,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     // Fetch glucose that is not uploaded to Nightscout yet
     // Fetch glucose that is not uploaded to Nightscout yet
     /// - Returns: Array of BloodGlucose to ensure the correct format for the NS Upload
     /// - Returns: Array of BloodGlucose to ensure the correct format for the NS Upload
     func getGlucoseNotYetUploadedToHealth() async throws -> [BloodGlucose] {
     func getGlucoseNotYetUploadedToHealth() async throws -> [BloodGlucose] {
+        let context = makeContext()
+        context.name = "getGlucoseNotYetUploadedToHealth"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             ofType: GlucoseStored.self,
             onContext: context,
             onContext: context,
@@ -509,6 +537,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     // Fetch manual glucose that is not uploaded to Nightscout yet
     // Fetch manual glucose that is not uploaded to Nightscout yet
     /// - Returns: Array of NightscoutTreatment to ensure the correct format for the NS Upload
     /// - Returns: Array of NightscoutTreatment to ensure the correct format for the NS Upload
     func getManualGlucoseNotYetUploadedToHealth() async throws -> [BloodGlucose] {
     func getManualGlucoseNotYetUploadedToHealth() async throws -> [BloodGlucose] {
+        let context = makeContext()
+        context.name = "getManualGlucoseNotYetUploadedToHealth"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             ofType: GlucoseStored.self,
             onContext: context,
             onContext: context,
@@ -541,6 +572,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     // Fetch glucose that is not uploaded to Tidepool yet
     // Fetch glucose that is not uploaded to Tidepool yet
     /// - Returns: Array of StoredGlucoseSample to ensure the correct format for Tidepool upload
     /// - Returns: Array of StoredGlucoseSample to ensure the correct format for Tidepool upload
     func getGlucoseNotYetUploadedToTidepool() async throws -> [StoredGlucoseSample] {
     func getGlucoseNotYetUploadedToTidepool() async throws -> [StoredGlucoseSample] {
+        let context = makeContext()
+        context.name = "getGlucoseNotYetUploadedToTidepool"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             ofType: GlucoseStored.self,
             onContext: context,
             onContext: context,
@@ -574,6 +608,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     // Fetch manual glucose that is not uploaded to Tidepool yet
     // Fetch manual glucose that is not uploaded to Tidepool yet
     /// - Returns: Array of StoredGlucoseSample to ensure the correct format for the Tidepool upload
     /// - Returns: Array of StoredGlucoseSample to ensure the correct format for the Tidepool upload
     func getManualGlucoseNotYetUploadedToTidepool() async throws -> [StoredGlucoseSample] {
     func getManualGlucoseNotYetUploadedToTidepool() async throws -> [StoredGlucoseSample] {
+        let context = makeContext()
+        context.name = "getManualGlucoseNotYetUploadedToTidepool"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             ofType: GlucoseStored.self,
             onContext: context,
             onContext: context,
@@ -726,16 +763,13 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 //    }
 //    }
 
 
     func deleteGlucose(_ treatmentObjectID: NSManagedObjectID) async {
     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 {
             do {
-                let result = try taskContext.existingObject(with: treatmentObjectID) as? GlucoseStored
+                let result = try context.existingObject(with: treatmentObjectID) as? GlucoseStored
 
 
                 guard let glucoseToDelete = result else {
                 guard let glucoseToDelete = result else {
                     debugPrint("Data Table State: \(#function) \(DebuggingIdentifiers.failed) glucose not found in core data")
                     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
                 // Create a new DeletedGlucoseStored object and copy the properties
                 if let date = glucoseToDelete.date {
                 if let date = glucoseToDelete.date {
-                    let deletedEntry = DeletedGlucoseStored(context: taskContext)
+                    let deletedEntry = DeletedGlucoseStored(context: context)
                     deletedEntry.date = date
                     deletedEntry.date = date
                     deletedEntry.glucose = glucoseToDelete.glucose
                     deletedEntry.glucose = glucoseToDelete.glucose
                     deletedEntry.isManualGlucoseEntry = glucoseToDelete.isManual
                     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")
                 debugPrint("\(#file) \(#function) \(DebuggingIdentifiers.succeeded) deleted glucose from core data")
             } catch {
             } catch {
                 debugPrint(
                 debugPrint(
@@ -764,10 +798,13 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
     }
 
 
     var alarm: GlucoseAlarm? {
     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
         /// glucose can not be older than 20 minutes due to the predicate in the fetch request
-        context.performAndWait {
+        return context.performAndWait {
             do {
             do {
-                guard let glucose = try fetchLatestGlucose() else { return nil }
+                guard let glucose = try fetchLatestGlucose(context: context) else { return nil }
 
 
                 let glucoseValue = glucose.glucose
                 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!
     @Injected() private var settingsManager: SettingsManager!
 
 
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
     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)
         injectServices(resolver)
     }
     }
 
 
@@ -41,6 +41,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     }
     }
 
 
     func fetchLastCreatedOverride() async throws -> [NSManagedObjectID] {
     func fetchLastCreatedOverride() async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "fetchLastCreatedOverride"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             ofType: OverrideStored.self,
             onContext: context,
             onContext: context,
@@ -63,6 +66,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     }
     }
 
 
     func loadLatestOverrideConfigurations(fetchLimit: Int) async throws -> [NSManagedObjectID] {
     func loadLatestOverrideConfigurations(fetchLimit: Int) async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "loadLatestOverrideConfigurations"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             ofType: OverrideStored.self,
             onContext: context,
             onContext: context,
@@ -83,6 +89,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
 
 
     /// Returns the NSManagedObjectID of the Override Presets
     /// Returns the NSManagedObjectID of the Override Presets
     func fetchForOverridePresets() async throws -> [NSManagedObjectID] {
     func fetchForOverridePresets() async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "fetchForOverridePresets"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             ofType: OverrideStored.self,
             onContext: context,
             onContext: context,
@@ -114,8 +123,11 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
             presetCount = presets.count
             presetCount = presets.count
         }
         }
 
 
+        let context = makeContext()
+        context.name = "storeOverride"
+
         try await context.perform {
         try await context.perform {
-            let newOverride = OverrideStored(context: self.context)
+            let newOverride = OverrideStored(context: context)
 
 
             // override key meta data
             // override key meta data
             if !override.name.isEmpty {
             if !override.name.isEmpty {
@@ -163,8 +175,8 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
                 newOverride.smbIsScheduledOff = false
                 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
     /// - Parameter: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
     func deleteOverridePreset(_ objectID: NSManagedObjectID) async {
     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"
         taskContext.transactionAuthor = "deleteOverride"
 
 
         await taskContext.perform {
         await taskContext.perform {
@@ -243,6 +251,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     }
     }
 
 
     func getOverridesNotYetUploadedToNightscout() async throws -> [NightscoutExercise] {
     func getOverridesNotYetUploadedToNightscout() async throws -> [NightscoutExercise] {
+        let context = makeContext()
+        context.name = "getOverridesNotYetUploadedToNightscout"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             ofType: OverrideStored.self,
             onContext: context,
             onContext: context,
@@ -271,6 +282,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     }
     }
 
 
     func getOverrideRunsNotYetUploadedToNightscout() async throws -> [NightscoutExercise] {
     func getOverrideRunsNotYetUploadedToNightscout() async throws -> [NightscoutExercise] {
+        let context = makeContext()
+        context.name = "getOverrideRunsNotYetUploadedToNightscout"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideRunStored.self,
             ofType: OverrideRunStored.self,
             onContext: context,
             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.
         /// 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 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(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             ofType: OverrideStored.self,
             onContext: context,
             onContext: context,
@@ -360,6 +377,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     }
     }
 
 
     func getPresetOverridesForNightscout() async throws -> [NightscoutPresetOverride] {
     func getPresetOverridesForNightscout() async throws -> [NightscoutPresetOverride] {
+        let context = makeContext()
+        context.name = "getPresetOverridesForNightscout"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             ofType: OverrideStored.self,
             onContext: context,
             onContext: context,
@@ -389,6 +409,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     }
     }
 
 
     func fetchLatestActiveOverride() async throws -> NSManagedObjectID? {
     func fetchLatestActiveOverride() async throws -> NSManagedObjectID? {
+        let context = makeContext()
+        context.name = "fetchLatestActiveOverride"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             ofType: OverrideStored.self,
             onContext: context,
             onContext: context,

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

@@ -31,10 +31,10 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
         updateSubject.eraseToAnyPublisher()
         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)
         injectServices(resolver)
     }
     }
 
 
@@ -47,178 +47,136 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     }
     }
 
 
     func storePumpEvents(_ events: [NewPumpEvent]) async throws {
     func storePumpEvents(_ events: [NewPumpEvent]) async throws {
+        let context = makeContext()
+        context.name = "storePumpEvents"
         try await context.perform {
         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 {
                 switch event.type {
                 case .bolus:
                 case .bolus:
-
                     guard let dose = event.dose else { continue }
                     guard let dose = event.dose else { continue }
                     let amount = self.roundDose(
                     let amount = self.roundDose(
                         dose.unitsInDeliverableIncrements,
                         dose.unitsInDeliverableIncrements,
                         toIncrement: Double(self.settings.preferences.bolusIncrement)
                         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
                         // Duplicate found, do not store the event
                         debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
                         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
                         continue
                     }
                     }
 
 
-                    let newPumpEvent = PumpEventStored(context: self.context)
+                    let newPumpEvent = PumpEventStored(context: context)
                     newPumpEvent.id = UUID().uuidString
                     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.type = PumpEvent.bolus.rawValue
                     newPumpEvent.isUploadedToNS = false
                     newPumpEvent.isUploadedToNS = false
                     newPumpEvent.isUploadedToHealth = false
                     newPumpEvent.isUploadedToHealth = false
                     newPumpEvent.isUploadedToTidepool = false
                     newPumpEvent.isUploadedToTidepool = false
 
 
-                    let newBolusEntry = BolusStored(context: self.context)
+                    let newBolusEntry = BolusStored(context: context)
                     newBolusEntry.pumpEvent = newPumpEvent
                     newBolusEntry.pumpEvent = newPumpEvent
                     newBolusEntry.amount = NSDecimalNumber(decimal: amount)
                     newBolusEntry.amount = NSDecimalNumber(decimal: amount)
                     newBolusEntry.isExternal = dose.manuallyEntered
                     newBolusEntry.isExternal = dose.manuallyEntered
                     newBolusEntry.isSMB = dose.automatic ?? true
                     newBolusEntry.isSMB = dose.automatic ?? true
 
 
+                    existingByKey[key] = newPumpEvent
+
                 case .tempBasal:
                 case .tempBasal:
                     guard let dose = event.dose else { continue }
                     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
                         continue
                     }
                     }
 
 
                     let rate = Decimal(dose.unitsPerHour)
                     let rate = Decimal(dose.unitsPerHour)
                     let minutes = (dose.endDate - dose.startDate).timeInterval / 60
                     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.pumpEvent = newPumpEvent
                     newTempBasal.duration = Int16(round(minutes))
                     newTempBasal.duration = Int16(round(minutes))
                     newTempBasal.rate = rate as NSDecimalNumber
                     newTempBasal.rate = rate as NSDecimalNumber
                     newTempBasal.tempType = TempType.absolute.rawValue
                     newTempBasal.tempType = TempType.absolute.rawValue
 
 
                 case .suspend:
                 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:
                 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:
                 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:
                 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:
                 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),
                 case .replaceComponent(componentType: .infusionSet),
                      .replaceComponent(componentType: .pump):
                      .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:
                 default:
                     continue
                     continue
@@ -226,8 +184,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
             }
             }
 
 
             do {
             do {
-                guard self.context.hasChanges else { return }
-                try self.context.save()
+                guard context.hasChanges else { return }
+                try context.save()
 
 
                 self.updateSubject.send(())
                 self.updateSubject.send(())
                 debug(.coreData, "\(DebuggingIdentifiers.succeeded) stored pump events in Core Data")
                 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 {
     func storeExternalInsulinEvent(amount: Decimal, timestamp: Date) async {
+        let context = makeContext()
+        context.name = "storeExternalInsulinEvent"
         await context.perform {
         await context.perform {
             // create pump event
             // create pump event
-            let newPumpEvent = PumpEventStored(context: self.context)
+            let newPumpEvent = PumpEventStored(context: context)
             newPumpEvent.id = UUID().uuidString
             newPumpEvent.id = UUID().uuidString
             // restrict entry to now or past
             // restrict entry to now or past
             newPumpEvent.timestamp = timestamp > Date() ? Date() : timestamp
             newPumpEvent.timestamp = timestamp > Date() ? Date() : timestamp
@@ -251,15 +211,15 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
             newPumpEvent.isUploadedToTidepool = false
             newPumpEvent.isUploadedToTidepool = false
 
 
             // create bolus entry and specify relationship to pump event
             // create bolus entry and specify relationship to pump event
-            let newBolusEntry = BolusStored(context: self.context)
+            let newBolusEntry = BolusStored(context: context)
             newBolusEntry.pumpEvent = newPumpEvent
             newBolusEntry.pumpEvent = newPumpEvent
             newBolusEntry.amount = amount as NSDecimalNumber
             newBolusEntry.amount = amount as NSDecimalNumber
             newBolusEntry.isExternal = true // we are creating an external dose
             newBolusEntry.isExternal = true // we are creating an external dose
             newBolusEntry.isSMB = false // the dose is manually administered
             newBolusEntry.isSMB = false // the dose is manually administered
 
 
             do {
             do {
-                guard self.context.hasChanges else { return }
-                try self.context.save()
+                guard context.hasChanges else { return }
+                try context.save()
                 debug(.coreData, "External insulin saved")
                 debug(.coreData, "External insulin saved")
                 self.updateSubject.send(())
                 self.updateSubject.send(())
             } catch {
             } catch {
@@ -269,12 +229,15 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     }
     }
 
 
     func getPumpHistory() async throws -> [PumpHistoryEvent] {
     func getPumpHistory() async throws -> [PumpHistoryEvent] {
+        let context = makeContext()
+        context.name = "getPumpHistory"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             ofType: PumpEventStored.self,
             onContext: context,
             onContext: context,
             predicate: NSPredicate.pumpHistoryLast24h,
             predicate: NSPredicate.pumpHistoryLast24h,
             key: "timestamp",
             key: "timestamp",
-            ascending: false
+            ascending: false,
+            relationshipKeyPathsForPrefetching: ["bolus", "tempBasal"]
         )
         )
 
 
         return await context.perform {
         return await context.perform {
@@ -318,12 +281,15 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     }
     }
 
 
     func getPumpHistoryNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
     func getPumpHistoryNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let context = makeContext()
+        context.name = "getPumpHistoryNotYetUploadedToNightscout"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             ofType: PumpEventStored.self,
             onContext: context,
             onContext: context,
             predicate: NSPredicate.pumpEventsNotYetUploadedToNightscout,
             predicate: NSPredicate.pumpEventsNotYetUploadedToNightscout,
             key: "timestamp",
             key: "timestamp",
-            ascending: false
+            ascending: false,
+            relationshipKeyPathsForPrefetching: ["bolus", "tempBasal"]
         )
         )
 
 
         return try await context.perform { [self] in
         return try await context.perform { [self] in
@@ -479,12 +445,15 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     }
     }
 
 
     func getPumpHistoryNotYetUploadedToHealth() async throws -> [PumpHistoryEvent] {
     func getPumpHistoryNotYetUploadedToHealth() async throws -> [PumpHistoryEvent] {
+        let context = makeContext()
+        context.name = "getPumpHistoryNotYetUploadedToHealth"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             ofType: PumpEventStored.self,
             onContext: context,
             onContext: context,
             predicate: NSPredicate.pumpEventsNotYetUploadedToHealth,
             predicate: NSPredicate.pumpEventsNotYetUploadedToHealth,
             key: "timestamp",
             key: "timestamp",
-            ascending: false
+            ascending: false,
+            relationshipKeyPathsForPrefetching: ["bolus", "tempBasal"]
         )
         )
 
 
         return try await context.perform {
         return try await context.perform {
@@ -523,12 +492,15 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     }
     }
 
 
     func getPumpHistoryNotYetUploadedToTidepool() async throws -> [PumpHistoryEvent] {
     func getPumpHistoryNotYetUploadedToTidepool() async throws -> [PumpHistoryEvent] {
+        let context = makeContext()
+        context.name = "getPumpHistoryNotYetUploadedToTidepool"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             ofType: PumpEventStored.self,
             onContext: context,
             onContext: context,
             predicate: NSPredicate.pumpEventsNotYetUploadedToTidepool,
             predicate: NSPredicate.pumpEventsNotYetUploadedToTidepool,
             key: "timestamp",
             key: "timestamp",
-            ascending: false
+            ascending: false,
+            relationshipKeyPathsForPrefetching: ["bolus", "tempBasal"]
         )
         )
 
 
         return try await context.perform {
         return try await context.perform {

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

@@ -28,9 +28,10 @@ struct TDDResult {
 final class BaseTDDStorage: TDDStorage, Injectable {
 final class BaseTDDStorage: TDDStorage, Injectable {
     @Injected() private var storage: FileStorage!
     @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)
         injectServices(resolver)
     }
     }
 
 
@@ -144,8 +145,10 @@ final class BaseTDDStorage: TDDStorage, Injectable {
     /// Stores the Total Daily Dose (TDD) result in Core Data
     /// 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
     /// - Parameter tddResult: The TDD result to store, containing total insulin, bolus, temp basal, scheduled basal and weighted average
     func storeTDD(_ tddResult: TDDResult) async {
     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.id = UUID()
             tddStored.date = Date()
             tddStored.date = Date()
             tddStored.total = NSDecimalNumber(decimal: tddResult.total)
             tddStored.total = NSDecimalNumber(decimal: tddResult.total)
@@ -155,8 +158,8 @@ final class BaseTDDStorage: TDDStorage, Injectable {
             tddStored.weightedAverage = tddResult.weightedAverage.map { NSDecimalNumber(decimal: $0) }
             tddStored.weightedAverage = tddResult.weightedAverage.map { NSDecimalNumber(decimal: $0) }
 
 
             do {
             do {
-                guard self.privateContext.hasChanges else { return }
-                try self.privateContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch {
             } catch {
                 debug(.apsManager, "\(DebuggingIdentifiers.failed) Failed to save TDD: \(error)")
                 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
     /// - 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)
     /// - Note: The weight percentage can be configured in preferences. Default is 0.65 (65% recent, 35% historical)
     private func calculateWeightedAverage() async throws -> Decimal? {
     private func calculateWeightedAverage() async throws -> Decimal? {
-        // Fetch data from Core Data
         let tenDaysAgo = Date().addingTimeInterval(-10.days.timeInterval)
         let tenDaysAgo = Date().addingTimeInterval(-10.days.timeInterval)
         let twoHoursAgo = Date().addingTimeInterval(-2.hours.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)
             // Get weight percentage from preferences (default 0.65 if not set)
             let userPreferences = self.storage.retrieve(OpenAPS.Settings.preferences, as: Preferences.self)
             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??
             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)
             // weightedTDD = (weightPercentage × recent_average) + ((1 - weightPercentage) × historical_average)
             let weightedTDD = weightPercentage * averageTDDLastTwoHours +
             let weightedTDD = weightPercentage * averageTDDLastTwoHours +
                 (1 - weightPercentage) * averageTDDLastTenDays
                 (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.
     /// 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:
     /// 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`.
     /// - Returns: `true` if sufficient TDD data is available, otherwise `false`.
     /// - Throws: An error if the Core Data count operation fails.
     /// - Throws: An error if the Core Data count operation fails.
     func hasSufficientTDD() async throws -> Bool {
     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
     /// 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 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)
         injectServices(resolver)
     }
     }
 
 
     func loadLatestTempTargetConfigurations(fetchLimit: Int) async throws -> [NSManagedObjectID] {
     func loadLatestTempTargetConfigurations(fetchLimit: Int) async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "loadLatestTempTargetConfigurations"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
             ofType: TempTargetStored.self,
             onContext: context,
             onContext: context,
@@ -61,6 +64,9 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
 
 
     /// Returns the NSManagedObjectID of the Temp Target Presets
     /// Returns the NSManagedObjectID of the Temp Target Presets
     func fetchForTempTargetPresets() async throws -> [NSManagedObjectID] {
     func fetchForTempTargetPresets() async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "fetchForTempTargetPresets"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
             ofType: TempTargetStored.self,
             onContext: context,
             onContext: context,
@@ -79,6 +85,9 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
     }
 
 
     func fetchScheduledTempTargets() async throws -> [NSManagedObjectID] {
     func fetchScheduledTempTargets() async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "fetchScheduledTempTargets"
+
         let scheduledTempTargets = NSPredicate(format: "date > %@", Date() as NSDate)
         let scheduledTempTargets = NSPredicate(format: "date > %@", Date() as NSDate)
 
 
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
@@ -99,6 +108,9 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
     }
 
 
     func fetchScheduledTempTarget(for targetDate: Date) async throws -> [NSManagedObjectID] {
     func fetchScheduledTempTarget(for targetDate: Date) async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "fetchScheduledTempTarget"
+
         let predicate = NSPredicate(format: "date == %@", targetDate as NSDate)
         let predicate = NSPredicate(format: "date == %@", targetDate as NSDate)
 
 
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
@@ -120,6 +132,9 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
     }
 
 
     func storeTempTarget(tempTarget: TempTarget) async throws {
     func storeTempTarget(tempTarget: TempTarget) async throws {
+        let context = makeContext()
+        context.name = "storeTempTarget"
+
         var presetCount = -1
         var presetCount = -1
         if tempTarget.isPreset == true {
         if tempTarget.isPreset == true {
             let presets = try await fetchForTempTargetPresets()
             let presets = try await fetchForTempTargetPresets()
@@ -127,7 +142,7 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
         }
         }
 
 
         try await context.perform {
         try await context.perform {
-            let newTempTarget = TempTargetStored(context: self.context)
+            let newTempTarget = TempTargetStored(context: context)
             newTempTarget.date = tempTarget.createdAt
             newTempTarget.date = tempTarget.createdAt
             newTempTarget.id = UUID()
             newTempTarget.id = UUID()
             newTempTarget.enabled = tempTarget.enabled ?? false
             newTempTarget.enabled = tempTarget.enabled ?? false
@@ -152,8 +167,8 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
             }
             }
 
 
             do {
             do {
-                guard self.context.hasChanges else { return }
-                try self.context.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
             } catch let error as NSError {
                 debug(.default, "\(DebuggingIdentifiers.failed) Failed to save new temp target with error: \(error.userInfo)")
                 debug(.default, "\(DebuggingIdentifiers.failed) Failed to save new temp target with error: \(error.userInfo)")
                 throw error
                 throw error
@@ -183,13 +198,16 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
     }
 
 
     func existsTempTarget(with date: Date) async -> Bool {
     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
             // Fetch all Temp Targets with the given date
             let fetchRequest: NSFetchRequest<TempTargetStored> = TempTargetStored.fetchRequest()
             let fetchRequest: NSFetchRequest<TempTargetStored> = TempTargetStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "date == %@", date as NSDate)
             fetchRequest.predicate = NSPredicate(format: "date == %@", date as NSDate)
 
 
             do {
             do {
-                let results = try self.context.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 return !results.isEmpty
                 return !results.isEmpty
             } catch let error as NSError {
             } catch let error as NSError {
                 debugPrint("\(DebuggingIdentifiers.failed) Failed to check for existing Temp Target: \(error)")
                 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 {
     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 {
             do {
-                let result = try taskContext.existingObject(with: objectID) as? TempTargetStored
+                let result = try context.existingObject(with: objectID) as? TempTargetStored
                 guard let tempTarget = result else {
                 guard let tempTarget = result else {
                     debug(.default, "\(DebuggingIdentifiers.failed) Temp Target for batch delete not found.")
                     debug(.default, "\(DebuggingIdentifiers.failed) Temp Target for batch delete not found.")
                     return
                     return
                 }
                 }
 
 
-                taskContext.delete(tempTarget)
+                context.delete(tempTarget)
 
 
-                guard taskContext.hasChanges else { return }
-                try taskContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch {
             } catch {
                 debug(.default, "\(DebuggingIdentifiers.failed) Failed to delete Temp Target: \(error)")
                 debug(.default, "\(DebuggingIdentifiers.failed) Failed to delete Temp Target: \(error)")
             }
             }
@@ -272,6 +289,9 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
     }
 
 
     func getTempTargetsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
     func getTempTargetsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let context = makeContext()
+        context.name = "getTempTargetsNotYetUploadedToNightscout"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
             ofType: TempTargetStored.self,
             onContext: context,
             onContext: context,
@@ -309,6 +329,9 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
     }
 
 
     func getTempTargetRunsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
     func getTempTargetRunsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let context = makeContext()
+        context.name = "getTempTargetRunsNotYetUploadedToNightscout"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetRunStored.self,
             ofType: TempTargetRunStored.self,
             onContext: context,
             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 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: "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: "FreeStyle Libre", predicate: { $0.type == .plugin && $0.displayName == "FreeStyle Libre" }),
+    CGMOption(name: "Eversense", predicate: { $0.type == .plugin && $0.displayName == "Eversense" }),
     CGMOption(
     CGMOption(
         name: "FreeStyle Libre Demo",
         name: "FreeStyle Libre Demo",
         predicate: { $0.type == .plugin && $0.displayName == "FreeStyle Libre Demo" }
         predicate: { $0.type == .plugin && $0.displayName == "FreeStyle Libre Demo" }

File diff ditekan karena terlalu besar
+ 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 useLiveActivity: Bool = false
     var lockScreenView: LockScreenView = .simple
     var lockScreenView: LockScreenView = .simple
     var smartStackView: LockScreenView = .simple
     var smartStackView: LockScreenView = .simple
+    var displayGlucoseForecasts: Bool = false
     var bolusShortcut: BolusShortcutLimit = .notAllowed
     var bolusShortcut: BolusShortcutLimit = .notAllowed
     var timeInRangeType: TimeInRangeType = .timeInTightRange
     var timeInRangeType: TimeInRangeType = .timeInTightRange
     var requireAdjustmentsConfirmation: Bool = false
     var requireAdjustmentsConfirmation: Bool = false
@@ -311,6 +312,10 @@ extension TrioSettings: Decodable {
             settings.smartStackView = smartStackView
             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) {
         if let bolusShortcut = try? container.decode(BolusShortcutLimit.self, forKey: .bolusShortcut) {
             settings.bolusShortcut = bolusShortcut
             settings.bolusShortcut = bolusShortcut
         }
         }

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

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

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

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

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

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

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

@@ -2,40 +2,30 @@ import CoreData
 import Foundation
 import Foundation
 
 
 extension Home.StateModel {
 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
         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
 import Foundation
 
 
 extension Home.StateModel {
 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
         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
         fpusFromPersistence = objects
     }
     }
 }
 }

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

@@ -1,111 +1,66 @@
+import CoreData
 import Foundation
 import Foundation
 
 
 extension Home.StateModel {
 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
 import Foundation
 
 
 extension Home.StateModel {
 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
 import Foundation
 
 
 extension Home.StateModel {
 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 []
                 return []
             }
             }
 
 
+            let taskContext = CoreDataStack.shared.newTaskContext()
+            taskContext.name = "HomeStateModel.preprocessForecastData"
+
             // Fetch complete forecast hierarchy with prefetched values
             // Fetch complete forecast hierarchy with prefetched values
             return try await determinationStorage.fetchForecastHierarchy(
             return try await determinationStorage.fetchForecastHierarchy(
                 for: determination.objectID,
                 for: determination.objectID,
@@ -30,12 +33,28 @@ extension Home.StateModel {
     }
     }
 
 
     // Update forecast data and UI on the main thread
     // 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 {
     @MainActor func updateForecastData() async {
         let forecastDataIDs = await preprocessForecastData()
         let forecastDataIDs = await preprocessForecastData()
 
 
+        guard !Task.isCancelled else { return }
+
         var allForecastValues = [[Int]]()
         var allForecastValues = [[Int]]()
         var preprocessedData = [(id: UUID, forecast: Forecast, forecastValue: ForecastValue)]()
         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
         // Process prefetched data directly
         for data in forecastDataIDs {
         for data in forecastDataIDs {
             if let forecast = try? viewContext.existingObject(with: data.forecastID) as? Forecast {
             if let forecast = try? viewContext.existingObject(with: data.forecastID) as? Forecast {
@@ -83,6 +102,8 @@ extension Home.StateModel {
             return (minForecast, maxForecast)
             return (minForecast, maxForecast)
         }.value
         }.value
 
 
+        guard !Task.isCancelled else { return }
+
         minForecast = minResult
         minForecast = minResult
         maxForecast = maxResult
         maxForecast = maxResult
     }
     }

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

@@ -2,47 +2,32 @@ import CoreData
 import Foundation
 import Foundation
 
 
 extension Home.StateModel {
 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
         glucoseFromPersistence = objects
         latestTwoGlucoseValues = Array(objects.suffix(2))
         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
 import Foundation
 
 
 extension Home.StateModel {
 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
         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
         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
     /// 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 {
     @MainActor func cancelOverride(withID id: NSManagedObjectID) async {
         do {
         do {

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

@@ -2,94 +2,59 @@ import CoreData
 import Foundation
 import Foundation
 
 
 extension Home.StateModel {
 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
         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
             $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 {
         do {
-            lastPumpBolus = try viewContext.existingObject(with: ID) as? PumpEventStored
+            try lastBolusController.performFetch()
+            updateLastBolusFromController()
         } catch {
         } 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
 import Foundation
 
 
 extension Home.StateModel {
 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
         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
         tempTargetRunStored = objects
     }
     }
 
 
+    // MARK: - Temp Target Actions
+
     @MainActor func cancelTempTarget(withID id: NSManagedObjectID) async {
     @MainActor func cancelTempTarget(withID id: NSManagedObjectID) async {
         do {
         do {
             guard let profileToCancel = try viewContext.existingObject(with: id) as? TempTargetStored else { return }
             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 tempBasals: [PumpEventStored] = []
         var suspendAndResumeEvents: [PumpEventStored] = []
         var suspendAndResumeEvents: [PumpEventStored] = []
         var batteryFromPersistence: [OpenAPS_Battery] = []
         var batteryFromPersistence: [OpenAPS_Battery] = []
+        var bolusStatus: BolusStatus = .noBolus
         var lastPumpBolus: PumpEventStored?
         var lastPumpBolus: PumpEventStored?
         var overrides: [OverrideStored] = []
         var overrides: [OverrideStored] = []
         var overrideRunStored: [OverrideRunStored] = []
         var overrideRunStored: [OverrideRunStored] = []
@@ -138,23 +139,272 @@ extension Home {
         var minValueIobChart: Decimal = 0
         var minValueIobChart: Decimal = 0
         var maxValueIobChart: Decimal = 5
         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
         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>()
         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
         typealias PumpEvent = PumpEventStored.EventType
 
 
         override init() {
         override init() {
@@ -162,14 +412,7 @@ extension Home {
         }
         }
 
 
         override func subscribe() {
         override func subscribe() {
-            coreDataPublisher =
-                changedObjectsOnManagedObjectContextDidSavePublisher()
-                    .receive(on: queue)
-                    .share()
-                    .eraseToAnyPublisher()
-
             registerSubscribers()
             registerSubscribers()
-            registerHandlers()
 
 
             // Parallelize Setup functions
             // Parallelize Setup functions
             setupHomeViewConcurrently()
             setupHomeViewConcurrently()
@@ -183,33 +426,25 @@ extension Home {
                 await self.setupCGMSettings()
                 await self.setupCGMSettings()
                 self.registerObservers()
                 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
                 // The rest can be initialized concurrently
                 await withTaskGroup(of: Void.self) { group in
                 await withTaskGroup(of: Void.self) { group in
                     group.addTask {
                     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()
                         await self.setupBasalProfile()
                     }
                     }
                     group.addTask {
                     group.addTask {
@@ -219,25 +454,12 @@ extension Home {
                         self.setupReservoir()
                         self.setupReservoir()
                     }
                     }
                     group.addTask {
                     group.addTask {
-                        self.setupOverrides()
-                    }
-                    group.addTask {
-                        self.setupOverrideRunStored()
-                    }
-                    group.addTask {
-                        self.setupTempTargetsStored()
-                    }
-                    group.addTask {
-                        self.setupTempTargetsRunStored()
-                    }
-                    group.addTask {
                         self.iobService.updateIOB()
                         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() {
         private func registerSubscribers() {
             iobService.iobPublisher
             iobService.iobPublisher
                 .receive(on: DispatchQueue.main)
                 .receive(on: DispatchQueue.main)
@@ -247,78 +469,16 @@ extension Home {
                 }
                 }
                 .store(in: &subscriptions)
                 .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)
                 .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() {
         private func registerObservers() {
             broadcaster.register(DeterminationObserver.self, observer: self)
             broadcaster.register(DeterminationObserver.self, observer: self)
             broadcaster.register(SettingsObserver.self, observer: self)
             broadcaster.register(SettingsObserver.self, observer: self)
@@ -373,6 +533,14 @@ extension Home {
             }
             }
             timer.resume()
             timer.resume()
 
 
+            Foundation.NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
+                .sink { [weak self] _ in
+                    Task { @MainActor in
+                        self?.reanchorFetchWindows()
+                    }
+                }
+                .store(in: &lifetime)
+
             fetchGlucoseManager.cgmDisplayState
             fetchGlucoseManager.cgmDisplayState
                 .receive(on: DispatchQueue.main)
                 .receive(on: DispatchQueue.main)
                 .sink { [weak self] state in self?.cgmDisplayState = state }
                 .sink { [weak self] state in self?.cgmDisplayState = state }
@@ -540,6 +708,8 @@ extension Home {
 
 
         func loadQuickBolusSuggestions() async {
         func loadQuickBolusSuggestions() async {
             guard enableQuickBolus else { return }
             guard enableQuickBolus else { return }
+
+            let fetchContext = CoreDataStack.shared.newTaskContext()
             let cutoff = Calendar.current.date(byAdding: .day, value: -90, to: Date()) ?? Date()
             let cutoff = Calendar.current.date(byAdding: .day, value: -90, to: Date()) ?? Date()
             let predicate = NSPredicate(
             let predicate = NSPredicate(
                 format: "isSMB == false AND isExternal == false AND pumpEvent.timestamp >= %@",
                 format: "isSMB == false AND isExternal == false AND pumpEvent.timestamp >= %@",
@@ -548,14 +718,14 @@ extension Home {
             do {
             do {
                 let results: Any = try await CoreDataStack.shared.fetchEntitiesAsync(
                 let results: Any = try await CoreDataStack.shared.fetchEntitiesAsync(
                     ofType: BolusStored.self,
                     ofType: BolusStored.self,
-                    onContext: pumpHistoryFetchContext,
+                    onContext: fetchContext,
                     predicate: predicate,
                     predicate: predicate,
                     key: "pumpEvent.timestamp",
                     key: "pumpEvent.timestamp",
                     ascending: false,
                     ascending: false,
                     batchSize: 100
                     batchSize: 100
                 )
                 )
 
 
-                let suggestions: [Decimal] = await pumpHistoryFetchContext.perform {
+                let suggestions: [Decimal] = await fetchContext.perform {
                     guard let boluses = results as? [BolusStored] else { return [] }
                     guard let boluses = results as? [BolusStored] else { return [] }
 
 
                     let now = Date()
                     let now = Date()
@@ -670,7 +840,7 @@ extension Home {
 
 
         /// Display the eventual status message provided by the manager of the pump
         /// Display the eventual status message provided by the manager of the pump
         /// Only display if state is warning or critical message else return nil
         /// 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
             DispatchQueue.main.async { [weak self] in
                 guard let self = self else { return }
                 guard let self = self else { return }
                 if let statusHighlight = self.provider.deviceManager.pumpManager?.pumpStatusHighlight,
                 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
             DispatchQueue.main.async { [weak self] in
                 guard let self = self else { return }
                 guard let self = self else { return }
                 if let statusBadge = self.provider.deviceManager.pumpManager?.pumpStatusBadge,
                 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()
                 drawSuspensions()
             }.onChange(of: state.tempBasals) {
             }.onChange(of: state.tempBasals) {
                 calculateBasals()
                 calculateBasals()
-                calculateTempBasalsInBackground()
+                calculateTempBasals()
             }
             }
             .onChange(of: state.maxBasal) {
             .onChange(of: state.maxBasal) {
                 calculateBasals()
                 calculateBasals()
@@ -137,34 +137,39 @@ extension MainChartView {
 // MARK: - Calculation
 // MARK: - Calculation
 
 
 extension MainChartView {
 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 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(
     func findRegularBasalPoints(

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

@@ -96,7 +96,7 @@ struct MainChartView: View {
                             if !mainChartHasInitialized {
                             if !mainChartHasInitialized {
                                 scroller.scrollTo("MainChart", anchor: .trailing)
                                 scroller.scrollTo("MainChart", anchor: .trailing)
                                 state.updateStartEndMarkers()
                                 state.updateStartEndMarkers()
-                                calculateTempBasalsInBackground()
+                                calculateTempBasals()
                                 mainChartHasInitialized = true
                                 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") +
                         + String(localized: " of ", comment: "Bolus string partial message: 'x U of y U' in home view") +
                         (Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
                         (Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
                         + String(localized: " U", comment: "Insulin unit")
                         + String(localized: " U", comment: "Insulin unit")
+                let bolusLabel = state
+                    .bolusStatus == .inProgress ? String(localized: "Bolusing") : String(localized: "Initiating…")
 
 
                 ZStack {
                 ZStack {
                     /// rectangle as background
                     /// rectangle as background
@@ -817,7 +819,7 @@ extension Home {
                         Spacer()
                         Spacer()
 
 
                         VStack {
                         VStack {
-                            Text("Bolusing")
+                            Text(bolusLabel)
                                 .font(.subheadline)
                                 .font(.subheadline)
                                 .frame(maxWidth: .infinity, alignment: .leading)
                                 .frame(maxWidth: .infinity, alignment: .leading)
                             Text(bolusString)
                             Text(bolusString)
@@ -827,12 +829,16 @@ extension Home {
 
 
                         Spacer()
                         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(.horizontal, 10)
                         .padding(.trailing, 8)
                         .padding(.trailing, 8)

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

@@ -24,8 +24,6 @@ extension ISFEditor {
         var therapyItems: [TherapySettingItem] = []
         var therapyItems: [TherapySettingItem] = []
         var shouldDisplaySaving: Bool = false
         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 }
         let timeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
 
 
         var rateValues: [Decimal] {
         var rateValues: [Decimal] {

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

@@ -9,11 +9,13 @@ extension LiveActivitySettings {
         @Published var useLiveActivity = false
         @Published var useLiveActivity = false
         @Published var lockScreenView: LockScreenView = .simple
         @Published var lockScreenView: LockScreenView = .simple
         @Published var smartStackView: LockScreenView = .simple
         @Published var smartStackView: LockScreenView = .simple
+        @Published var displayGlucoseForecasts = false
         override func subscribe() {
         override func subscribe() {
             units = settingsManager.settings.units
             units = settingsManager.settings.units
             subscribeSetting(\.useLiveActivity, on: $useLiveActivity) { useLiveActivity = $0 }
             subscribeSetting(\.useLiveActivity, on: $useLiveActivity) { useLiveActivity = $0 }
             subscribeSetting(\.lockScreenView, on: $lockScreenView) { lockScreenView = $0 }
             subscribeSetting(\.lockScreenView, on: $lockScreenView) { lockScreenView = $0 }
             subscribeSetting(\.smartStackView, on: $smartStackView) { smartStackView = $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 shouldDisplayHintLockScreen: Bool = false
         @State private var shouldDisplayHintSmartStack: Bool = false
         @State private var shouldDisplayHintSmartStack: Bool = false
+        @State private var shouldDisplayHintGlucoseForecasts: Bool = false
         @State var hintDetent = PresentationDetent.large
         @State var hintDetent = PresentationDetent.large
         @State var selectedVerboseHint: AnyView?
         @State var selectedVerboseHint: AnyView?
         @State var hintLabel: String?
         @State var hintLabel: String?
@@ -83,6 +84,30 @@ extension LiveActivitySettings {
                     )
                     )
 
 
                     if state.useLiveActivity {
                     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 {
                         Section {
                             VStack {
                             VStack {
                                 Picker(
                                 Picker(
@@ -229,6 +254,15 @@ extension LiveActivitySettings {
                     sheetTitle: String(localized: "Help", comment: "Help sheet title")
                     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))
             .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
             .onAppear(perform: configureView)
             .onAppear(perform: configureView)
             .navigationTitle("Live Activity")
             .navigationTitle("Live Activity")

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

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

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

@@ -3,6 +3,7 @@ import LoopKit
 import LoopKitUI
 import LoopKitUI
 import SwiftUI
 import SwiftUI
 import Swinject
 import Swinject
+import UIKit
 
 
 extension Settings {
 extension Settings {
     struct VersionInfo: Equatable {
     struct VersionInfo: Equatable {
@@ -34,6 +35,7 @@ extension Settings {
             isDevUpdateAvailable: false
             isDevUpdateAvailable: false
         )
         )
         @State private var closedLoopDisabled = true
         @State private var closedLoopDisabled = true
+        @State private var showCopiedToast = false
 
 
         @Environment(\.colorScheme) var colorScheme
         @Environment(\.colorScheme) var colorScheme
         @EnvironmentObject var appIcons: Icons
         @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 {
         var body: some View {
             List {
             List {
                 if searchText.isEmpty {
                 if searchText.isEmpty {
                     let buildDetails = BuildDetails.shared
                     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)) {
                             NavigationLink(destination: SubmodulesView(buildDetails: buildDetails)) {
                                 HStack {
                                 HStack {
                                     Image(appIcons.appIcon.rawValue)
                                     Image(appIcons.appIcon.rawValue)
@@ -310,6 +331,18 @@ extension Settings {
                     ).listRowBackground(Color.chart)
                     ).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))
             .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
             .sheet(isPresented: $shouldDisplayHint) {
             .sheet(isPresented: $shouldDisplayHint) {
                 SettingInputHintView(
                 SettingInputHintView(

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

@@ -28,10 +28,12 @@ struct KeyValueRow: View {
         HStack {
         HStack {
             Text(key)
             Text(key)
                 .foregroundColor(.primary)
                 .foregroundColor(.primary)
+                .textSelection(.enabled)
             Spacer()
             Spacer()
             Text(value)
             Text(value)
                 .foregroundColor(.secondary)
                 .foregroundColor(.secondary)
                 .multilineTextAlignment(.trailing)
                 .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"),
                     name: String(localized: "Lock Screen Widget Style"),
                     value: trioSettings.lockScreenView.rawValue
                     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
             // 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
     /// 3. Calculates total insulin for each time period
     /// 4. Returns the processed statistics as (hourly: [BolusStats], daily: [BolusStats])
     /// 4. Returns the processed statistics as (hourly: [BolusStats], daily: [BolusStats])
     private func fetchBolusStats() async throws -> (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
         // Fetch PumpEventStored entries from Core Data
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: BolusStored.self,
             ofType: BolusStored.self,
@@ -155,6 +158,9 @@ extension Stat.StateModel {
     ///
     ///
     /// This only needs to be called once during subscribe.
     /// This only needs to be called once during subscribe.
     private func calculateAndCacheBolusAveragesAndTotals() async {
     private func calculateAndCacheBolusAveragesAndTotals() async {
+        let bolusTaskContext = CoreDataStack.shared.newTaskContext()
+        bolusTaskContext.name = "StatStateModel.calculateAndCacheBolusAveragesAndTotals"
+
         let calendar = Calendar.current
         let calendar = Calendar.current
 
 
         // Calculate averages in context
         // 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
     func fetchLoopStatRecords(for interval: StatsTimeIntervalWithToday) async throws
         -> ([NSManagedObjectID], [NSManagedObjectID])
         -> ([NSManagedObjectID], [NSManagedObjectID])
     {
     {
+        let loopTaskContext = CoreDataStack.shared.newTaskContext()
+        loopTaskContext.name = "StatStateModel.fetchLoopStatRecords"
+
         // Calculate the date range based on selected duration
         // Calculate the date range based on selected duration
         let now = Date()
         let now = Date()
         let startDate: Date
         let startDate: Date
@@ -154,6 +157,9 @@ extension Stat.StateModel {
     ) async throws
     ) async throws
         -> [LoopStatsProcessedData]
         -> [LoopStatsProcessedData]
     {
     {
+        let loopTaskContext = CoreDataStack.shared.newTaskContext()
+        loopTaskContext.name = "StatStateModel.getLoopStats"
+
         // Calculate the date range for glucose readings
         // Calculate the date range for glucose readings
         let now = Date()
         let now = Date()
         let startDate: Date
         let startDate: Date
@@ -170,7 +176,7 @@ extension Stat.StateModel {
             startDate = now.addingTimeInterval(-90.days.timeInterval)
             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)
         let totalGlucose = try await calculateGlucoseStats(from: startDate, to: now)
 
 
         // Get NSManagedObject
         // Get NSManagedObject
@@ -236,6 +242,9 @@ extension Stat.StateModel {
         from startDate: Date,
         from startDate: Date,
         to _: Date
         to _: Date
     ) async throws -> Int {
     ) async throws -> Int {
+        let loopTaskContext = CoreDataStack.shared.newTaskContext()
+        loopTaskContext.name = "StatStateModel.calculateGlucoseStats"
+
         // Create predicate for glucose readings
         // Create predicate for glucose readings
         let glucosePredicate = NSPredicate(format: "date >= %@", startDate as NSDate)
         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
     /// 3. Calculates total macronutrients for each time period
     /// 4. Returns the processed statistics as (hourly: [MealStats], daily: [MealStats])
     /// 4. Returns the processed statistics as (hourly: [MealStats], daily: [MealStats])
     private func fetchMealStats() async throws -> (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
         // Fetch CarbEntryStored entries from Core Data
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             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
     /// - Returns: A tuple containing hourly and daily TDD statistics arrays
     /// - Note: Processes both hourly statistics for the last 10 days and complete daily statistics
     /// - Note: Processes both hourly statistics for the last 10 days and complete daily statistics
     private func fetchTDDStats() async throws -> (hourly: [TDDStats], daily: [TDDStats]) {
     private func fetchTDDStats() async throws -> (hourly: [TDDStats], daily: [TDDStats]) {
+        let tddTaskContext = CoreDataStack.shared.newTaskContext()
+        tddTaskContext.name = "StatStateModel.fetchTDDStats"
+
         // MARK: - Fetch Required Data
         // MARK: - Fetch Required Data
 
 
         // Fetch data for daily statistics (TDDStored for week, month, total views)
         // 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)
         // 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
         // MARK: - Process Data on Background Context
 
 
@@ -77,7 +85,7 @@ extension Stat.StateModel {
     /// Fetches TDDStored records from CoreData for daily statistics
     /// Fetches TDDStored records from CoreData for daily statistics
     /// - Returns: The results of the fetch request containing TDDStored records
     /// - Returns: The results of the fetch request containing TDDStored records
     /// - Note: Fetches records from the last 3 months for week, month, and total views
     /// - 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
         // Create a predicate to fetch TDD records from the last 3 months
         let threeMonthsAgo = Date().addingTimeInterval(-3.months.timeInterval)
         let threeMonthsAgo = Date().addingTimeInterval(-3.months.timeInterval)
         let predicate = NSPredicate(format: "date >= %@", threeMonthsAgo as NSDate)
         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
     /// Fetches BolusStored and TempBasalStored records from CoreData for hourly statistics
     /// - Returns: A tuple containing the results of both fetch requests
     /// - Returns: A tuple containing the results of both fetch requests
     /// - Note: Fetches records from the last 20 days for detailed hourly view
     /// - 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)
         // Calculate date range for hourly statistics (last 20 days)
         let now = Date()
         let now = Date()
         let twentyDaysAgo = Calendar.current.date(byAdding: .day, value: -20, to: now) ?? now
         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
     /// 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
     /// - Note: This function runs asynchronously and updates the tddAveragesCache on the main actor
     private func calculateAndCacheTDDAverages() async {
     private func calculateAndCacheTDDAverages() async {
+        let tddTaskContext = CoreDataStack.shared.newTaskContext()
+        tddTaskContext.name = "StatStateModel.calculateAndCacheTDDAverages"
+
         // Get calendar for date calculations
         // Get calendar for date calculations
         let calendar = Calendar.current
         let calendar = Calendar.current
 
 

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

@@ -77,12 +77,7 @@ extension Stat {
         var selectedMealChartType: MealChartType = .totalMeals
         var selectedMealChartType: MealChartType = .totalMeals
 
 
         // Fetching Contexts
         // Fetching Contexts
-        let context = CoreDataStack.shared.newTaskContext()
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext
         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() {
         override func subscribe() {
             setupGlucoseArray(for: .today)
             setupGlucoseArray(for: .today)
@@ -128,6 +123,9 @@ extension Stat {
 
 
         private func fetchGlucose(for interval: StatsTimeIntervalWithToday) async -> [NSManagedObjectID] {
         private func fetchGlucose(for interval: StatsTimeIntervalWithToday) async -> [NSManagedObjectID] {
             do {
             do {
+                let context = CoreDataStack.shared.newTaskContext()
+                context.name = "StatStateModel.fetchGlucose"
+
                 let predicate: NSPredicate
                 let predicate: NSPredicate
 
 
                 switch interval {
                 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 preprocessedData: [(id: UUID, forecast: Forecast, forecastValue: ForecastValue)] = []
         var predictionsForChart: Predictions?
         var predictionsForChart: Predictions?
         var simulatedDetermination: Determination?
         var simulatedDetermination: Determination?
-        @MainActor var determinationObjectIDs: [NSManagedObjectID] = []
 
 
         var minForecast: [Int] = []
         var minForecast: [Int] = []
         var maxForecast: [Int] = []
         var maxForecast: [Int] = []
@@ -122,24 +121,73 @@ extension Treatments {
         let now = Date.now
         let now = Date.now
 
 
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext
         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 isActive: Bool = false
 
 
         var showDeterminationFailureAlert = false
         var showDeterminationFailureAlert = false
         var determinationFailureMessage = ""
         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>()
         private var subscriptions = Set<AnyCancellable>()
 
 
         typealias PumpEvent = PumpEventStored.EventType
         typealias PumpEvent = PumpEventStored.EventType
 
 
         var bolusProgress: Decimal?
         var bolusProgress: Decimal?
-        var isBolusInProgress: Bool { bolusProgress != nil }
+        var bolusStatus: BolusStatus = .noBolus
         var lastPumpBolus: PumpEventStored?
         var lastPumpBolus: PumpEventStored?
 
 
         func unsubscribe() {
         func unsubscribe() {
@@ -153,13 +201,6 @@ extension Treatments {
             }
             }
 
 
             debug(.bolusState, "subscribe fired")
             debug(.bolusState, "subscribe fired")
-            coreDataPublisher =
-                changedObjectsOnManagedObjectContextDidSavePublisher()
-                    .receive(on: queue)
-                    .share()
-                    .eraseToAnyPublisher()
-            registerHandlers()
-            registerSubscribers()
             setupBolusStateConcurrently()
             setupBolusStateConcurrently()
             subscribeToBolusProgress()
             subscribeToBolusProgress()
         }
         }
@@ -170,6 +211,10 @@ extension Treatments {
 
 
         private var hasCleanedUp = false
         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() {
         func cleanupTreatmentState() {
             guard !hasCleanedUp else { return }
             guard !hasCleanedUp else { return }
             hasCleanedUp = true
             hasCleanedUp = true
@@ -177,6 +222,16 @@ extension Treatments {
             unsubscribe()
             unsubscribe()
             lifetime = Lifetime()
             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(DeterminationObserver.self, observer: self)
             broadcaster?.unregister(BolusFailureObserver.self, observer: self)
             broadcaster?.unregister(BolusFailureObserver.self, observer: self)
 
 
@@ -185,14 +240,13 @@ extension Treatments {
 
 
         private func setupBolusStateConcurrently() {
         private func setupBolusStateConcurrently() {
             debug(.bolusState, "Setting up bolus state concurrently...")
             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 {
                 do {
                     try await withThrowingTaskGroup(of: Void.self) { group in
                     try await withThrowingTaskGroup(of: Void.self) { group in
                         group.addTask {
                         group.addTask {
-                            self.setupGlucoseArray()
-                        }
-                        group.addTask {
-                            self.setupDeterminationsAndForecasts()
+                            await self.getAllSettingsValues()
                         }
                         }
                         group.addTask {
                         group.addTask {
                             await self.setupSettings()
                             await self.setupSettings()
@@ -200,9 +254,6 @@ extension Treatments {
                         group.addTask {
                         group.addTask {
                             self.registerObservers()
                             self.registerObservers()
                         }
                         }
-                        group.addTask {
-                            self.setupLastBolus()
-                        }
 
 
                         // Wait for all tasks to complete
                         // Wait for all tasks to complete
                         try await group.waitForAll()
                         try await group.waitForAll()
@@ -210,6 +261,14 @@ extension Treatments {
                 } catch let error as NSError {
                 } catch let error as NSError {
                     debug(.default, "Failed to setup bolus state concurrently: \(error)")
                     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)
                 .receive(on: DispatchQueue.main)
                 .weakAssign(to: \.bolusProgress, on: self)
                 .weakAssign(to: \.bolusProgress, on: self)
                 .store(in: &lifetime)
                 .store(in: &lifetime)
+
+            provider.deviceManager.bolusTrigger
+                .receive(on: DispatchQueue.main)
+                .weakAssign(to: \.bolusStatus, on: self)
+                .store(in: &lifetime)
         }
         }
 
 
         func cancelBolus() {
         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() {
         private func registerObservers() {
             broadcaster.register(DeterminationObserver.self, observer: self)
             broadcaster.register(DeterminationObserver.self, observer: self)
             broadcaster.register(BolusFailureObserver.self, observer: self)
             broadcaster.register(BolusFailureObserver.self, observer: self)
@@ -408,6 +458,9 @@ extension Treatments {
                 isBackdated: isBackdated
                 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
             // Update state properties with calculation results on main thread
             await MainActor.run {
             await MainActor.run {
                 targetDifference = result.targetDifference
                 targetDifference = result.targetDifference
@@ -723,6 +776,8 @@ extension Treatments.StateModel: DeterminationObserver, BolusFailureObserver {
 
 
     func bolusDidFail() {
     func bolusDidFail() {
         DispatchQueue.main.async {
         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")
             debug(.bolusState, "bolusDidFail fired")
             self.isAwaitingDeterminationResult = false
             self.isAwaitingDeterminationResult = false
             if self.addButtonPressed {
             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 {
 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
         // Store all objects for the forecast graph
         glucoseFromPersistence = objects
         glucoseFromPersistence = objects
 
 
@@ -832,96 +838,89 @@ extension Treatments.StateModel {
         deltaBG = delta
         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 {
         } 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
             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
             simulatedDetermination = forecastData
             debugPrint("\(DebuggingIdentifiers.failed) minPredBG: \(minPredBG)")
             debugPrint("\(DebuggingIdentifiers.failed) minPredBG: \(minPredBG)")
         } else {
         } else {
-            simulatedDetermination = await Task { [self] in
+            let simulated = await Task { [self] in
                 debug(.bolusState, "calling simulateDetermineBasal to get forecast data")
                 debug(.bolusState, "calling simulateDetermineBasal to get forecast data")
                 return await apsManager.simulateDetermineBasal(
                 return await apsManager.simulateDetermineBasal(
                     simulatedCarbsAmount: carbs,
                     simulatedCarbsAmount: carbs,
@@ -946,8 +945,12 @@ extension Treatments.StateModel {
                 )
                 )
             }.value
             }.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
             // Update evBG and minPredBG from simulated determination
-            if let simDetermination = simulatedDetermination {
+            if let simDetermination = simulated {
                 evBG = Decimal(simDetermination.eventualBG ?? 0)
                 evBG = Decimal(simDetermination.eventualBG ?? 0)
                 minPredBG = simDetermination.minPredBGFromReason ?? 0
                 minPredBG = simDetermination.minPredBGFromReason ?? 0
                 debugPrint("\(DebuggingIdentifiers.inProgress) minPredBG: \(minPredBG)")
                 debugPrint("\(DebuggingIdentifiers.inProgress) minPredBG: \(minPredBG)")
@@ -986,18 +989,13 @@ extension Treatments.StateModel {
             }
             }
         }.value
         }.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
 // MARK: - Last Pump Bolus
 
 
 extension Treatments.StateModel {
 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).
     /// running pump-bolus's amount as the denominator (not the user's pending entry).
     /// Filters out external boluses via `NSPredicate.lastPumpBolus`.
     /// 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 {
         do {
-            lastPumpBolus = try viewContext.existingObject(with: id) as? PumpEventStored
+            try lastBolusController.performFetch()
+            updateLastBolusFromController()
         } catch {
         } 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 {
         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)
                 !state.externalInsulin && (state.carbs == 0 || state.fat == 0 || state.protein == 0)
 
 
             var treatmentButtonBackground = Color(.systemBlue)
             var treatmentButtonBackground = Color(.systemBlue)
@@ -568,6 +568,7 @@ extension Treatments {
                     + (Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
                     + (Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
                     + String(localized: " U", comment: "Insulin unit")
                     + String(localized: " U", comment: "Insulin unit")
             }()
             }()
+            let bolusLabel = state.bolusStatus == .inProgress ? String(localized: "Bolusing") : String(localized: "Initiating…")
 
 
             ZStack {
             ZStack {
                 // background card
                 // background card
@@ -593,7 +594,7 @@ extension Treatments {
                     Spacer()
                     Spacer()
 
 
                     VStack {
                     VStack {
-                        Text("Bolusing")
+                        Text(bolusLabel)
                             .font(.subheadline)
                             .font(.subheadline)
                             .frame(maxWidth: .infinity, alignment: .leading)
                             .frame(maxWidth: .infinity, alignment: .leading)
                         Text(bolusString)
                         Text(bolusString)
@@ -604,12 +605,16 @@ extension Treatments {
 
 
                     Spacer()
                     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(.horizontal, 10)
                 .padding(.trailing, 8)
                 .padding(.trailing, 8)
@@ -690,7 +695,7 @@ extension Treatments {
 
 
         private var disableTaskButton: Bool {
         private var disableTaskButton: Bool {
             (
             (
-                state.isBolusInProgress && state
+                state.bolusStatus != .noBolus && state
                     .amount > 0 && !state.externalInsulin && (state.carbs == 0 || state.fat == 0 || state.protein == 0)
                     .amount > 0 && !state.externalInsulin && (state.carbs == 0 || state.fat == 0 || state.protein == 0)
             ) || state
             ) || state
                 .addButtonPressed || limitExceeded
                 .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 settingsManager: SettingsManager!
     @Injected() private var fetchGlucoseManager: FetchGlucoseManager!
     @Injected() private var fetchGlucoseManager: FetchGlucoseManager!
 
 
-    private let coreDataContext = CoreDataStack.shared.newTaskContext()
     private let evaluationQueue = DispatchQueue(label: "GlucoseAlertCoordinator.queue")
     private let evaluationQueue = DispatchQueue(label: "GlucoseAlertCoordinator.queue")
     private var firingAlertIDs: Set<UUID> = []
     private var firingAlertIDs: Set<UUID> = []
     private var subscriptions = Set<AnyCancellable>()
     private var subscriptions = Set<AnyCancellable>()
@@ -403,16 +402,18 @@ final class GlucoseAlertCoordinator: Injectable {
     private func fetchLatestReadingMgDL() async -> Decimal? {
     private func fetchLatestReadingMgDL() async -> Decimal? {
         let cutoff = Date().addingTimeInterval(-Self.readingFreshnessWindow)
         let cutoff = Date().addingTimeInterval(-Self.readingFreshnessWindow)
         let predicate = NSPredicate(format: "date >= %@", cutoff as NSDate)
         let predicate = NSPredicate(format: "date >= %@", cutoff as NSDate)
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "GlucoseAlertCoordinator.fetchLatestReading"
         do {
         do {
             let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             let results = try await CoreDataStack.shared.fetchEntitiesAsync(
                 ofType: GlucoseStored.self,
                 ofType: GlucoseStored.self,
-                onContext: coreDataContext,
+                onContext: context,
                 predicate: predicate,
                 predicate: predicate,
                 key: "date",
                 key: "date",
                 ascending: false,
                 ascending: false,
                 fetchLimit: 1
                 fetchLimit: 1
             )
             )
-            return await coreDataContext.perform {
+            return await context.perform {
                 guard let latest = (results as? [GlucoseStored])?.first else { return nil }
                 guard let latest = (results as? [GlucoseStored])?.first else { return nil }
                 return Decimal(latest.glucose)
                 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 fileStorage: FileStorage!
     @Injected() private var determinationStorage: DeterminationStorage!
     @Injected() private var determinationStorage: DeterminationStorage!
 
 
-    let glucoseFetchContext = CoreDataStack.shared.newTaskContext()
-    let determinationFetchContext = CoreDataStack.shared.newTaskContext()
-
     init(resolver: Resolver) {
     init(resolver: Resolver) {
         injectServices(resolver)
         injectServices(resolver)
     }
     }
@@ -191,15 +188,17 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
     /// Fetches recent glucose readings from CoreData
     /// Fetches recent glucose readings from CoreData
     /// - Returns: Array of NSManagedObjectIDs for glucose readings
     /// - Returns: Array of NSManagedObjectIDs for glucose readings
     private func fetchGlucose() async throws -> [NSManagedObjectID] {
     private func fetchGlucose() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchGlucose"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             ofType: GlucoseStored.self,
-            onContext: glucoseFetchContext,
+            onContext: context,
             predicate: NSPredicate.glucose,
             predicate: NSPredicate.glucose,
             key: "date",
             key: "date",
             ascending: false
             ascending: false
         )
         )
 
 
-        return try await glucoseFetchContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [GlucoseStored] else {
             guard let fetchedResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
             }
@@ -312,6 +311,8 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
             let maxCOB = preferences.maxCOB
             let maxCOB = preferences.maxCOB
 
 
             // Fetch glucose data
             // Fetch glucose data
+            let glucoseFetchContext = CoreDataStack.shared.newTaskContext()
+            glucoseFetchContext.name = "handleBolusCalculation.glucose"
             let glucoseIds = try await fetchGlucose()
             let glucoseIds = try await fetchGlucose()
             let glucoseObjects: [GlucoseStored] = try await CoreDataStack.shared.getNSManagedObject(
             let glucoseObjects: [GlucoseStored] = try await CoreDataStack.shared.getNSManagedObject(
                 with: glucoseIds,
                 with: glucoseIds,
@@ -322,6 +323,8 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
             }
             }
 
 
             // Fetch determination data
             // Fetch determination data
+            let determinationFetchContext = CoreDataStack.shared.newTaskContext()
+            determinationFetchContext.name = "handleBolusCalculation.determination"
             let determinationIds = try await determinationStorage.fetchLastDeterminationObjectID(
             let determinationIds = try await determinationStorage.fetchLastDeterminationObjectID(
                 predicate: NSPredicate.predicateFor30MinAgoForDetermination
                 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 settingsManager: SettingsManager!
     @Injected() private var iobService: IOBService!
     @Injected() private var iobService: IOBService!
 
 
-    private let fetchContext: NSManagedObjectContext
-
     init(resolver: Resolver) {
     init(resolver: Resolver) {
-        fetchContext = CoreDataStack.shared.newTaskContext()
         injectServices(resolver)
         injectServices(resolver)
     }
     }
 
 
@@ -84,6 +81,8 @@ final class BaseBolusSafetyValidator: BolusSafetyValidator, Injectable {
     }
     }
 
 
     func fetchTotalRecentBolusAmount(since date: Date) async throws -> Decimal {
     func fetchTotalRecentBolusAmount(since date: Date) async throws -> Decimal {
+        let fetchContext = CoreDataStack.shared.newTaskContext()
+        fetchContext.name = "fetchTotalRecentBolusAmount"
         let predicate = NSPredicate(
         let predicate = NSPredicate(
             format: "type == %@ AND timestamp > %@",
             format: "type == %@ AND timestamp > %@",
             PumpEventStored.EventType.bolus.rawValue,
             PumpEventStored.EventType.bolus.rawValue,

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

@@ -65,7 +65,7 @@ final class BaseCalendarManager: CalendarManager, Injectable {
         setupCurrentCalendar()
         setupCurrentCalendar()
 
 
         coreDataPublisher =
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .receive(on: queue)
                 .share()
                 .share()
                 .eraseToAnyPublisher()
                 .eraseToAnyPublisher()
@@ -77,7 +77,6 @@ final class BaseCalendarManager: CalendarManager, Injectable {
         registerSubscribers()
         registerSubscribers()
     }
     }
 
 
-    let backgroundContext = CoreDataStack.shared.newTaskContext()
     let viewContext = CoreDataStack.shared.persistentContainer.viewContext
     let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
 
     private func setupCurrentCalendar() {
     private func setupCurrentCalendar() {
@@ -177,9 +176,11 @@ final class BaseCalendarManager: CalendarManager, Injectable {
     }
     }
 
 
     private func getLastDetermination() async throws -> NSManagedObjectID? {
     private func getLastDetermination() async throws -> NSManagedObjectID? {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "getLastDetermination"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
             ofType: OrefDetermination.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.predicateFor30MinAgoForDetermination,
             predicate: NSPredicate.predicateFor30MinAgoForDetermination,
             key: "timestamp",
             key: "timestamp",
             ascending: false,
             ascending: false,
@@ -187,7 +188,7 @@ final class BaseCalendarManager: CalendarManager, Injectable {
             propertiesToFetch: ["timestamp", "cob", "iob", "objectID"]
             propertiesToFetch: ["timestamp", "cob", "iob", "objectID"]
         )
         )
 
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [[String: Any]] else {
             guard let fetchedResults = results as? [[String: Any]] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
             }
@@ -197,16 +198,18 @@ final class BaseCalendarManager: CalendarManager, Injectable {
     }
     }
 
 
     private func fetchGlucose() async throws -> [NSManagedObjectID] {
     private func fetchGlucose() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchGlucose"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             ofType: GlucoseStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.predicateFor30MinAgo,
             predicate: NSPredicate.predicateFor30MinAgo,
             key: "date",
             key: "date",
             ascending: false,
             ascending: false,
             propertiesToFetch: ["objectID", "glucose"]
             propertiesToFetch: ["objectID", "glucose"]
         )
         )
 
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [[String: Any]] else {
             guard let fetchedResults = results as? [[String: Any]] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
                 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(set) var state = ContactImageState()
 
 
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-    private let backgroundContext = CoreDataStack.shared.newTaskContext()
 
 
     // Queue for handling Core Data change notifications
     // Queue for handling Core Data change notifications
     private let queue = DispatchQueue(label: "BaseContactImageManager.queue", qos: .background)
     private let queue = DispatchQueue(label: "BaseContactImageManager.queue", qos: .background)
@@ -56,7 +55,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
         injectServices(resolver)
         injectServices(resolver)
         units = settingsManager.settings.units
         units = settingsManager.settings.units
         coreDataPublisher =
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .receive(on: queue)
                 .share()
                 .share()
                 .eraseToAnyPublisher()
                 .eraseToAnyPublisher()
@@ -101,16 +100,18 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
     // MARK: - Core Data Fetches
     // MARK: - Core Data Fetches
 
 
     private func fetchlastDetermination() async throws -> [NSManagedObjectID] {
     private func fetchlastDetermination() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchlastDetermination"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
             ofType: OrefDetermination.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate(format: "deliverAt >= %@", Date.halfHourAgo as NSDate), // fetches enacted and suggested
             predicate: NSPredicate(format: "deliverAt >= %@", Date.halfHourAgo as NSDate), // fetches enacted and suggested
             key: "deliverAt",
             key: "deliverAt",
             ascending: false,
             ascending: false,
             fetchLimit: 1
             fetchLimit: 1
         )
         )
 
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [OrefDetermination] else {
             guard let fetchedResults = results as? [OrefDetermination] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
             }
@@ -120,16 +121,18 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
     }
     }
 
 
     private func fetchGlucose() async throws -> [NSManagedObjectID] {
     private func fetchGlucose() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchGlucose"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             ofType: GlucoseStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.predicateFor20MinAgo,
             predicate: NSPredicate.predicateFor20MinAgo,
             key: "date",
             key: "date",
             ascending: false,
             ascending: false,
             fetchLimit: 3 /// We only need 1-3 values, depending on whether the user wants to show delta or not
             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 {
             guard let glucoseResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
                 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 pumpHistoryStorage: PumpHistoryStorage!
     @Injected() private var deviceDataManager: DeviceDataManager!
     @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 {
     var isAvailableOnCurrentDevice: Bool {
         HKHealthStore.isHealthDataAvailable()
         HKHealthStore.isHealthDataAvailable()
@@ -69,23 +122,7 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
     init(resolver: Resolver) {
     init(resolver: Resolver) {
         injectServices(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,
         guard isAvailableOnCurrentDevice,
               AppleHealthConfig.healthBGObject != nil else { return }
               AppleHealthConfig.healthBGObject != nil else { return }
@@ -93,31 +130,27 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
         debug(.service, "HealthKitManager did create")
         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 {
     func checkWriteToHealthPermissions(objectTypeToHealthStore: HKObjectType) -> Bool {
@@ -214,19 +247,21 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
     }
     }
 
 
     private func updateGlucoseAsUploaded(_ glucose: [BloodGlucose]) async {
     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 ids = glucose.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
             let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
 
             do {
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                 for result in results {
                     result.isUploadedToHealth = true
                     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 {
             } catch let error as NSError {
                 debugPrint(
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToHealth: \(error.userInfo)"
                     "\(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 {
     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 ids = carbs.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<CarbEntryStored> = CarbEntryStored.fetchRequest()
             let fetchRequest: NSFetchRequest<CarbEntryStored> = CarbEntryStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
 
             do {
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                 for result in results {
                     result.isUploadedToHealth = true
                     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 {
             } catch let error as NSError {
                 debugPrint(
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToHealth: \(error.userInfo)"
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToHealth: \(error.userInfo)"
@@ -377,9 +414,11 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
               insulinEvents.isNotEmpty else { return }
               insulinEvents.isNotEmpty else { return }
 
 
         do {
         do {
+            let context = CoreDataStack.shared.newTaskContext()
+            context.name = "uploadInsulin"
             let fetchedInsulinEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
             let fetchedInsulinEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
                 ofType: PumpEventStored.self,
                 ofType: PumpEventStored.self,
-                onContext: backgroundContext,
+                onContext: context,
                 predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [
                 predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [
                     NSPredicate.pumpHistoryLast24h,
                     NSPredicate.pumpHistoryLast24h,
                     NSPredicate(format: "tempBasal != nil")
                     NSPredicate(format: "tempBasal != nil")
@@ -391,7 +430,7 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
 
 
             var insulinSamples: [HKQuantitySample] = []
             var insulinSamples: [HKQuantitySample] = []
 
 
-            try await backgroundContext.perform {
+            try await context.perform {
                 guard let existingTempBasalEntries = fetchedInsulinEntries as? [PumpEventStored] else {
                 guard let existingTempBasalEntries = fetchedInsulinEntries as? [PumpEventStored] else {
                     throw CoreDataError.fetchError(function: #function, file: #file)
                     throw CoreDataError.fetchError(function: #function, file: #file)
                 }
                 }
@@ -582,19 +621,21 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
     }
     }
 
 
     private func updateInsulinAsUploaded(_ insulin: [PumpHistoryEvent]) async {
     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 ids = insulin.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
             let fetchRequest: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
 
             do {
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                 for result in results {
                     result.isUploadedToHealth = true
                     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 {
             } catch let error as NSError {
                 debugPrint(
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToHealth: \(error.userInfo)"
                     "\(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 subscriptions = Set<AnyCancellable>()
     private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
     private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
     private let queue = DispatchQueue(label: "BaseIOBService.queue", qos: .background)
     private let queue = DispatchQueue(label: "BaseIOBService.queue", qos: .background)
-    private let context = CoreDataStack.shared.newTaskContext()
 
 
     init(resolver: Resolver) {
     init(resolver: Resolver) {
         injectServices(resolver)
         injectServices(resolver)
         coreDataPublisher =
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .receive(on: queue)
                 .share()
                 .share()
                 .eraseToAnyPublisher()
                 .eraseToAnyPublisher()
@@ -64,6 +63,8 @@ final class BaseIOBService: IOBService, Injectable {
     private func fetchLatestDeterminationIOB() -> (iob: Decimal?, date: Date?) {
     private func fetchLatestDeterminationIOB() -> (iob: Decimal?, date: Date?) {
         var iob: Decimal?
         var iob: Decimal?
         var date: Date?
         var date: Date?
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchLatestDeterminationIOB"
         context.performAndWait {
         context.performAndWait {
             let request = OrefDetermination.fetchRequest() as NSFetchRequest<OrefDetermination>
             let request = OrefDetermination.fetchRequest() as NSFetchRequest<OrefDetermination>
             request.sortDescriptors = [NSSortDescriptor(key: "deliverAt", ascending: false)]
             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, *)
 @available(iOS 16.2, *)
 extension LiveActivityManager {
 extension LiveActivityManager {
     func fetchAndMapGlucose() async throws -> [GlucoseData] {
     func fetchAndMapGlucose() async throws -> [GlucoseData] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchAndMapGlucose"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             ofType: GlucoseStored.self,
             onContext: context,
             onContext: context,
@@ -27,6 +29,8 @@ extension LiveActivityManager {
 
 
     // TODO: extract logic or at least rename function appropiately
     // TODO: extract logic or at least rename function appropiately
     func fetchAndMapDetermination() async throws -> DeterminationData? {
     func fetchAndMapDetermination() async throws -> DeterminationData? {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchAndMapDetermination"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
             ofType: OrefDetermination.self,
             onContext: context,
             onContext: context,
@@ -34,7 +38,7 @@ extension LiveActivityManager {
             key: "deliverAt",
             key: "deliverAt",
             ascending: false,
             ascending: false,
             fetchLimit: 1,
             fetchLimit: 1,
-            propertiesToFetch: ["cob", "currentTarget", "deliverAt"]
+            relationshipKeyPathsForPrefetching: ["forecasts", "forecasts.forecastValues"]
         )
         )
 
 
         let tddResults = try await CoreDataStack.shared.fetchEntitiesAsync(
         let tddResults = try await CoreDataStack.shared.fetchEntitiesAsync(
@@ -48,7 +52,9 @@ extension LiveActivityManager {
         )
         )
 
 
         return try await context.perform {
         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)
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
             }
 
 
@@ -58,11 +64,44 @@ extension LiveActivityManager {
 
 
             let tddValue = (tddResults.first?["total"] as? NSDecimalNumber)?.decimalValue ?? 0
             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(
             return DeterminationData(
-                cob: (determination["cob"] as? Int) ?? 0,
+                cob: Int(determination.cob),
                 tdd: tddValue,
                 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],
         propertiesToFetch: [String],
         map: @escaping ([String: Any]) -> Output
         map: @escaping ([String: Any]) -> Output
     ) async throws -> Output? {
     ) async throws -> Output? {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchAndMapLatest"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: type,
             ofType: type,
             onContext: context,
             onContext: context,

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

@@ -5,4 +5,7 @@ struct DeterminationData {
     let tdd: Decimal
     let tdd: Decimal
     let target: Decimal
     let target: Decimal
     let date: Date?
     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 tempTargetDuration: Decimal
         let tempTargetTarget: Decimal
         let tempTargetTarget: Decimal
         let widgetItems: [LiveActivityItem]
         let widgetItems: [LiveActivityItem]
+        let minForecast: [Int]
+        let maxForecast: [Int]
+        let forecastLines: [ForecastLine]
+        let forecastDisplayType: String
     }
     }
 
 
     struct ChartItem: Codable, Hashable {
     struct ChartItem: Codable, Hashable {
@@ -56,5 +60,10 @@ struct LiveActivityAttributes: ActivityAttributes {
         let date: Date
         let date: Date
     }
     }
 
 
+    struct ForecastLine: Codable, Hashable {
+        let type: String
+        let values: [Int]
+    }
+
     let startDate: Date
     let startDate: Date
 }
 }

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

@@ -118,7 +118,16 @@ extension LiveActivityAttributes.ContentState {
             tempTargetDate: tempTarget?.date ?? Date(),
             tempTargetDate: tempTarget?.date ?? Date(),
             tempTargetDuration: tempTarget?.duration ?? 0,
             tempTargetDuration: tempTarget?.duration ?? 0,
             tempTargetTarget: tempTarget?.target ?? 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(
         self.init(

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

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

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

@@ -5,12 +5,10 @@ import Foundation
 extension BaseNightscoutManager {
 extension BaseNightscoutManager {
     /// Call once from init. Hooks up:
     /// Call once from init. Hooks up:
     /// 1) external upload requests (NotificationCenter)
     /// 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() {
     func wireSubscribers() {
         wireExternalUploadRequests()
         wireExternalUploadRequests()
-        wireCoreDataSubscribers()
-        wireGlucoseStorageSubscriber()
+        wireUploadControllers()
     }
     }
 
 
     /// Listens for `.nightscoutUploadRequested`, converts userInfo pipelines to enums,
     /// Listens for `.nightscoutUploadRequested`, converts userInfo pipelines to enums,
@@ -31,59 +29,50 @@ extension BaseNightscoutManager {
             .store(in: &subscriptions)
             .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.
     // Queue where upload pipelines run.
     let uploadPipelineQueue = DispatchQueue(label: "NightscoutManager.uploadPipelines", qos: .utility)
     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
     /// Throttle window (seconds) per upload pipeline. Any requests inside this window
     /// coalesce into a single upload run for that pipeline.
     /// coalesce into a single upload run for that pipeline.
     let uploadPipelineInterval: [NightscoutUploadPipeline: TimeInterval] = [
     let uploadPipelineInterval: [NightscoutUploadPipeline: TimeInterval] = [
@@ -130,26 +127,174 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     private var lastEnactedDetermination: Determination?
     private var lastEnactedDetermination: Determination?
     private var lastSuggestedDetermination: 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.
     /// Bag for Combine subscriptions owned by this manager.
     var subscriptions = Set<AnyCancellable>()
     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) {
     init(resolver: Resolver) {
         injectServices(resolver)
         injectServices(resolver)
         subscribe()
         subscribe()
 
 
-        coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
-                .receive(on: queue)
-                .share()
-                .eraseToAnyPublisher()
-
         setupNotification()
         setupNotification()
 
 
         setupLanePipelines()
         setupLanePipelines()
@@ -356,9 +501,11 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
     }
 
 
     private func fetchBattery() async -> Battery {
     private func fetchBattery() async -> Battery {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchBattery"
+        return await context.perform {
             do {
             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 {
                 if let last = results.first {
                     let percent: Int? = Int(last.percent)
                     let percent: Int? = Int(last.percent)
                     let voltage: Decimal? = last.voltage as Decimal?
                     let voltage: Decimal? = last.voltage as Decimal?
@@ -414,16 +561,18 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
             return
             return
         }
         }
 
 
+        let tddContext = CoreDataStack.shared.newTaskContext()
+        tddContext.name = "uploadDeviceStatus.tdd"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TDDStored.self,
             ofType: TDDStored.self,
-            onContext: backgroundContext,
+            onContext: tddContext,
             predicate: NSPredicate.predicateFor30MinAgo,
             predicate: NSPredicate.predicateFor30MinAgo,
             key: "date",
             key: "date",
             ascending: false,
             ascending: false,
             fetchLimit: 1
             fetchLimit: 1
         )
         )
 
 
-        let tdd: Decimal? = await backgroundContext.perform {
+        let tdd: Decimal? = await tddContext.perform {
             (results as? [TDDStored])?.first?.total as? Decimal
             (results as? [TDDStored])?.first?.total as? Decimal
         }
         }
 
 
@@ -588,19 +737,21 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
     }
 
 
     private func updateOrefDeterminationAsUploaded(_ determination: [Determination]) async {
     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 ids = determination.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<OrefDetermination> = OrefDetermination.fetchRequest()
             let fetchRequest: NSFetchRequest<OrefDetermination> = OrefDetermination.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
 
             do {
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                 for result in results {
                     result.isUploadedToNS = true
                     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 {
             } catch let error as NSError {
                 debugPrint(
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
                     "\(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 {
     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 ids = glucose.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
             let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
 
             do {
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                 for result in results {
                     result.isUploadedToNS = true
                     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 {
             } catch let error as NSError {
                 debugPrint(
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
                     "\(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 {
     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 ids = treatments.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
             let fetchRequest: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
 
             do {
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                 for result in results {
                     result.isUploadedToNS = true
                     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 {
             } catch let error as NSError {
                 debugPrint(
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
                     "\(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 {
     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 ids = treatments.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<CarbEntryStored> = CarbEntryStored.fetchRequest()
             let fetchRequest: NSFetchRequest<CarbEntryStored> = CarbEntryStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
 
             do {
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                 for result in results {
                     result.isUploadedToNS = true
                     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 {
             } catch let error as NSError {
                 debugPrint(
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
                     "\(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 {
     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 ids = overrides.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<OverrideStored> = OverrideStored.fetchRequest()
             let fetchRequest: NSFetchRequest<OverrideStored> = OverrideStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
 
             do {
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                 for result in results {
                     result.isUploadedToNS = true
                     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 {
             } catch let error as NSError {
                 debugPrint(
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
                     "\(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 {
     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 ids = overrideRuns.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<OverrideRunStored> = OverrideRunStored.fetchRequest()
             let fetchRequest: NSFetchRequest<OverrideRunStored> = OverrideRunStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
 
             do {
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                 for result in results {
                     result.isUploadedToNS = true
                     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 {
             } catch let error as NSError {
                 debugPrint(
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
                     "\(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 {
     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 ids = tempTargets.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<TempTargetStored> = TempTargetStored.fetchRequest()
             let fetchRequest: NSFetchRequest<TempTargetStored> = TempTargetStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
 
             do {
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                 for result in results {
                     result.isUploadedToNS = true
                     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 {
             } catch let error as NSError {
                 debugPrint(
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS for TempTargetStored: \(error.userInfo)"
                     "\(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 {
     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 ids = tempTargetRuns.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<TempTargetRunStored> = TempTargetRunStored.fetchRequest()
             let fetchRequest: NSFetchRequest<TempTargetRunStored> = TempTargetRunStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
 
             do {
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                 for result in results {
                     result.isUploadedToNS = true
                     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 {
             } catch let error as NSError {
                 debugPrint(
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS for TempTargetRunStored: \(error.userInfo)"
                     "\(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?
     @PersistedProperty(key: "TidepoolState") var rawTidepoolManager: Service.RawValue?
 
 
@@ -73,23 +126,33 @@ final class BaseTidepoolManager: TidepoolManager, Injectable {
         injectServices(resolver)
         injectServices(resolver)
         loadTidepoolManager()
         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
     /// Loads the Tidepool service from saved state
@@ -128,38 +191,6 @@ final class BaseTidepoolManager: TidepoolManager, Injectable {
         return nil
         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]? {
     func sourceInfo() -> [String: Any]? {
         nil
         nil
     }
     }
@@ -246,19 +277,21 @@ extension BaseTidepoolManager {
     }
     }
 
 
     private func updateCarbsAsUploaded(_ carbs: [CarbsEntry]) async {
     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 ids = carbs.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<CarbEntryStored> = CarbEntryStored.fetchRequest()
             let fetchRequest: NSFetchRequest<CarbEntryStored> = CarbEntryStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
 
             do {
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                 for result in results {
                     result.isUploadedToTidepool = true
                     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 {
             } catch let error as NSError {
                 debugPrint(
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToTidepool: \(error.userInfo)"
                     "\(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 }
         guard !events.isEmpty, let tidepoolService = self.tidepoolService else { return }
 
 
         do {
         do {
+            let context = CoreDataStack.shared.newTaskContext()
+            context.name = "uploadDose"
             // Fetch all temp basal entries from Core Data for the last 24 hours
             // Fetch all temp basal entries from Core Data for the last 24 hours
             let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             let results = try await CoreDataStack.shared.fetchEntitiesAsync(
                 ofType: PumpEventStored.self,
                 ofType: PumpEventStored.self,
-                onContext: backgroundContext,
+                onContext: context,
                 predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [
                 predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [
                     NSPredicate.pumpHistoryLast24h,
                     NSPredicate.pumpHistoryLast24h,
                     NSPredicate(format: "tempBasal != nil")
                     NSPredicate(format: "tempBasal != nil")
@@ -330,7 +365,7 @@ extension BaseTidepoolManager {
             )
             )
 
 
             // Ensure that the processing happens within the background context for thread safety
             // 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 {
                 guard let existingTempBasalEntries = results as? [PumpEventStored] else {
                     throw CoreDataError.fetchError(function: #function, file: #file)
                     throw CoreDataError.fetchError(function: #function, file: #file)
                 }
                 }
@@ -432,19 +467,21 @@ extension BaseTidepoolManager {
     }
     }
 
 
     private func updateInsulinAsUploaded(_ insulin: [PumpHistoryEvent]) async {
     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 ids = insulin.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
             let fetchRequest: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
 
             do {
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                 for result in results {
                     result.isUploadedToTidepool = true
                     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 {
             } catch let error as NSError {
                 debugPrint(
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToTidepool: \(error.userInfo)"
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToTidepool: \(error.userInfo)"
@@ -486,79 +523,77 @@ extension BaseTidepoolManager {
     ) -> [DoseEntry] {
     ) -> [DoseEntry] {
         var insulinDoseEvents: [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
         return insulinDoseEvents
@@ -651,19 +686,21 @@ extension BaseTidepoolManager {
     }
     }
 
 
     private func updateGlucoseAsUploaded(_ glucose: [StoredGlucoseSample]) async {
     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 ids = glucose.map(\.syncIdentifier) as NSArray
             let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
             let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
 
             do {
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                 for result in results {
                     result.isUploadedToTidepool = true
                     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 {
             } catch let error as NSError {
                 debugPrint(
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToTidepool: \(error.userInfo)"
                     "\(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) {
     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
         // prevent ObjectIdentifier be reused
         if contains(object) {
         if contains(object) {
             remove(object)
             remove(object)
@@ -48,6 +51,11 @@ struct WeakObjectSet<T: AnyObject>: Sequence {
         objects.insert(WeakObject(object))
         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]) {
     mutating func add(_ objects: [T]) {
         objects.forEach { self.add($0) }
         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 notificationCenter = UNUserNotificationCenter.current()
 
 
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-    private let backgroundContext = CoreDataStack.shared.newTaskContext()
 
 
     // Queue for handling Core Data change notifications
     // Queue for handling Core Data change notifications
     private let queue = DispatchQueue(label: "BaseUserNotificationsManager.queue", qos: .userInitiated)
     private let queue = DispatchQueue(label: "BaseUserNotificationsManager.queue", qos: .userInitiated)
@@ -48,7 +47,7 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         injectServices(resolver)
         injectServices(resolver)
 
 
         coreDataPublisher =
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .receive(on: queue)
                 .share()
                 .share()
                 .eraseToAnyPublisher()
                 .eraseToAnyPublisher()
@@ -130,16 +129,18 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     }
     }
 
 
     private func fetchGlucoseIDs() async throws -> [NSManagedObjectID] {
     private func fetchGlucoseIDs() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchGlucoseIDs"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             ofType: GlucoseStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.predicateFor20MinAgo,
             predicate: NSPredicate.predicateFor20MinAgo,
             key: "date",
             key: "date",
             ascending: false,
             ascending: false,
             fetchLimit: 3
             fetchLimit: 3
         )
         )
 
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [GlucoseStored] else {
             guard let fetchedResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
                 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
     typealias PumpEvent = PumpEventStored.EventType
 
 
-    let backgroundContext = CoreDataStack.shared.newTaskContext()
     let viewContext = CoreDataStack.shared.persistentContainer.viewContext
     let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
 
     init(resolver: Resolver) {
     init(resolver: Resolver) {
@@ -61,7 +60,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
 
         // Observer for OrefDetermination and adjustments
         // Observer for OrefDetermination and adjustments
         coreDataPublisher =
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .receive(on: queue)
                 .share()
                 .share()
                 .eraseToAnyPublisher()
                 .eraseToAnyPublisher()
@@ -184,6 +183,9 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
             return WatchState(date: Date())
             return WatchState(date: Date())
         }
         }
         do {
         do {
+            let context = CoreDataStack.shared.newTaskContext()
+            context.name = "setupWatchState"
+
             // Get NSManagedObjectIDs
             // Get NSManagedObjectIDs
             let glucoseIds = try await fetchGlucose()
             let glucoseIds = try await fetchGlucose()
             let determinationIds = try await determinationStorage.fetchLastDeterminationObjectID(
             let determinationIds = try await determinationStorage.fetchLastDeterminationObjectID(
@@ -194,15 +196,15 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
 
             // Get NSManagedObjects
             // Get NSManagedObjects
             let glucoseObjects: [GlucoseStored] = try await CoreDataStack.shared
             let glucoseObjects: [GlucoseStored] = try await CoreDataStack.shared
-                .getNSManagedObject(with: glucoseIds, context: backgroundContext)
+                .getNSManagedObject(with: glucoseIds, context: context)
             let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared
             let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared
-                .getNSManagedObject(with: determinationIds, context: backgroundContext)
+                .getNSManagedObject(with: determinationIds, context: context)
             let overridePresetObjects: [OverrideStored] = try await CoreDataStack.shared
             let overridePresetObjects: [OverrideStored] = try await CoreDataStack.shared
-                .getNSManagedObject(with: overridePresetIds, context: backgroundContext)
+                .getNSManagedObject(with: overridePresetIds, context: context)
             let tempTargetPresetObjects: [TempTargetStored] = try await CoreDataStack.shared
             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())
                 var watchState = WatchState(date: Date())
 
 
                 // Set lastLoopDate
                 // Set lastLoopDate
@@ -374,15 +376,17 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
     /// Fetches recent glucose readings from CoreData
     /// Fetches recent glucose readings from CoreData
     /// - Returns: Array of NSManagedObjectIDs for glucose readings
     /// - Returns: Array of NSManagedObjectIDs for glucose readings
     private func fetchGlucose() async throws -> [NSManagedObjectID] {
     private func fetchGlucose() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchGlucose"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             ofType: GlucoseStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.glucose,
             predicate: NSPredicate.glucose,
             key: "date",
             key: "date",
             ascending: false
             ascending: false
         )
         )
 
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [GlucoseStored] else {
             guard let fetchedResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
                 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
     /// Fetches last pump event that is a non-external bolus from CoreData
     /// - Returns: NSManagedObjectIDs for last bolus
     /// - Returns: NSManagedObjectIDs for last bolus
     func fetchLastBolus() async throws -> NSManagedObjectID? {
     func fetchLastBolus() async throws -> NSManagedObjectID? {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchLastBolus"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             ofType: PumpEventStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.lastPumpBolus,
             predicate: NSPredicate.lastPumpBolus,
             key: "timestamp",
             key: "timestamp",
             ascending: false,
             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 {
             guard let fetchedResults = results as? [PumpEventStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
             }
@@ -640,13 +647,15 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                     guard let self = self else { return }
                     guard let self = self else { return }
 
 
                     do {
                     do {
+                        let context = CoreDataStack.shared.newTaskContext()
+                        context.name = "requestBolusRecommendation"
                         // Fetch determination data
                         // Fetch determination data
                         let determinationIds = try await determinationStorage.fetchLastDeterminationObjectID(
                         let determinationIds = try await determinationStorage.fetchLastDeterminationObjectID(
                             predicate: NSPredicate.predicateFor30MinAgoForDetermination
                             predicate: NSPredicate.predicateFor30MinAgoForDetermination
                         )
                         )
                         let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared.getNSManagedObject(
                         let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared.getNSManagedObject(
                             with: determinationIds,
                             with: determinationIds,
-                            context: backgroundContext
+                            context: context
                         )
                         )
 
 
                         await MainActor.run {
                         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.
     /// Additional local subscriptions (separate from `cancellables`) for CoreData events.
     private var subscriptions = Set<AnyCancellable>()
     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.
     /// Represents the main (view) context for CoreData, typically used on the main thread.
     let viewContext = CoreDataStack.shared.persistentContainer.viewContext
     let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
 
@@ -198,7 +195,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
         broadcaster.register(SettingsObserver.self, observer: self)
         broadcaster.register(SettingsObserver.self, observer: self)
 
 
         coreDataPublisher =
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .receive(on: queue)
                 .share()
                 .share()
                 .eraseToAnyPublisher()
                 .eraseToAnyPublisher()
@@ -456,16 +453,18 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
     /// - Parameter limit: Maximum number of glucose entries to fetch (default: 2)
     /// - Parameter limit: Maximum number of glucose entries to fetch (default: 2)
     /// - Returns: An array of `NSManagedObjectID`s for glucose readings.
     /// - Returns: An array of `NSManagedObjectID`s for glucose readings.
     private func fetchGlucose(limit: Int = 2) async throws -> [NSManagedObjectID] {
     private func fetchGlucose(limit: Int = 2) async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchGlucose"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             ofType: GlucoseStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.glucose,
             predicate: NSPredicate.glucose,
             key: "date",
             key: "date",
             ascending: false,
             ascending: false,
             fetchLimit: limit
             fetchLimit: limit
         )
         )
 
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [GlucoseStored] else {
             guard let fetchedResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
                 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.
     /// 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.
     /// - Returns: An array containing the NSManagedObjectID of the latest temp basal event, if any.
     private func fetchTempBasals() async throws -> [NSManagedObjectID] {
     private func fetchTempBasals() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchTempBasals"
         let tempBasalPredicate = NSPredicate(format: "tempBasal != nil")
         let tempBasalPredicate = NSPredicate(format: "tempBasal != nil")
         let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
         let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
             NSPredicate.pumpHistoryLast24h,
             NSPredicate.pumpHistoryLast24h,
@@ -484,14 +485,15 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
 
 
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             ofType: PumpEventStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: compoundPredicate,
             predicate: compoundPredicate,
             key: "timestamp",
             key: "timestamp",
             ascending: false,
             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 {
             guard let pumpEvents = results as? [PumpEventStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
                 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 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.
     /// - Returns: An array of `NSManagedObjectID`s for all determinations in the 30-minute window.
     private func fetchDeterminations30Min() async throws -> [NSManagedObjectID] {
     private func fetchDeterminations30Min() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchDeterminations30Min"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
             ofType: OrefDetermination.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.predicateFor30MinAgoForDetermination,
             predicate: NSPredicate.predicateFor30MinAgoForDetermination,
             key: "deliverAt",
             key: "deliverAt",
             ascending: false,
             ascending: false,
             fetchLimit: 0 // No limit - get all determinations in 30min window
             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 {
             guard let fetchedResults = results as? [OrefDetermination] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
             }
@@ -558,7 +562,8 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
         let previousWatchState = lastPreparedWatchState
         let previousWatchState = lastPreparedWatchState
 
 
         // Capture context locally for use in perform block
         // Capture context locally for use in perform block
-        let context = backgroundContext
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "setupGarminWatchState"
 
 
         let watchStates = await context.perform {
         let watchStates = await context.perform {
             // Fetch Core Data objects inside perform block
             // Fetch Core Data objects inside perform block

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

@@ -21,7 +21,6 @@ import Swinject
 
 
     let resolver: Resolver
     let resolver: Resolver
 
 
-    let coredataContext = CoreDataStack.shared.newTaskContext()
     let viewContext = CoreDataStack.shared.persistentContainer.viewContext
     let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
 
     override init() {
     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.
      - Throws: An error if fetching fails or Core Data operations fail.
      */
      */
     func fetchAndProcessOverrides() async throws -> [OverridePreset] {
     func fetchAndProcessOverrides() async throws -> [OverridePreset] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchAndProcessOverrides"
+
         do {
         do {
             // Fetch all Override Presets via OverrideStorage
             // Fetch all Override Presets via OverrideStorage
             let allOverridePresetsIDs = try await overrideStorage.fetchForOverridePresets()
             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
             // 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
                 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
                 return overrideObjects.map { object in
@@ -51,12 +54,15 @@ import UIKit
      - Throws: `overridePresetsError.noTempOverrideFound` if no presets are found.
      - Throws: `overridePresetsError.noTempOverrideFound` if no presets are found.
      */
      */
     func fetchIDs(_ uuid: [OverridePreset.ID]) async throws -> [OverridePreset] {
     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()
             let fetchRequest: NSFetchRequest<OverrideStored> = OverrideStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", uuid)
             fetchRequest.predicate = NSPredicate(format: "id IN %@", uuid)
 
 
             do {
             do {
-                let result = try self.coredataContext.fetch(fetchRequest)
+                let result = try context.fetch(fetchRequest)
 
 
                 if result.isEmpty {
                 if result.isEmpty {
                     debug(
                     debug(
@@ -87,12 +93,15 @@ import UIKit
      - Throws: `overridePresetsError.noTempOverrideFound` if the preset is not found.
      - Throws: `overridePresetsError.noTempOverrideFound` if the preset is not found.
      */
      */
     private func fetchOverrideID(_ preset: OverridePreset) async throws -> NSManagedObjectID {
     private func fetchOverrideID(_ preset: OverridePreset) async throws -> NSManagedObjectID {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchOverrideID"
+
         let fetchRequest: NSFetchRequest<OverrideStored> = OverrideStored.fetchRequest()
         let fetchRequest: NSFetchRequest<OverrideStored> = OverrideStored.fetchRequest()
         fetchRequest.predicate = NSPredicate(format: "id == %@", preset.id)
         fetchRequest.predicate = NSPredicate(format: "id == %@", preset.id)
         fetchRequest.fetchLimit = 1
         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(
                 debug(
                     .default,
                     .default,
                     "\(DebuggingIdentifiers.failed) No override found for preset: \(preset.name)"
                     "\(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 {
 final class StateIntentRequest: BaseIntentsRequest {
-    let moc = CoreDataStack.shared.newTaskContext()
-
     func getLastGlucose(onContext: NSManagedObjectContext) throws
     func getLastGlucose(onContext: NSManagedObjectContext) throws
         -> (dateGlucose: Date, glucose: String, trend: String, delta: String)
         -> (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.
     /// - Returns: An array of `TempPreset` objects.
     /// - Throws: An error if fetching or processing fails.
     /// - Throws: An error if fetching or processing fails.
     func fetchAndProcessTempTargets() async throws -> [TempPreset] {
     func fetchAndProcessTempTargets() async throws -> [TempPreset] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchAndProcessTempTargets"
+
         // Fetch all Temp Target Presets via TempTargetStorage
         // Fetch all Temp Target Presets via TempTargetStorage
         let allTempTargetPresetsIDs = try await tempTargetsStorage.fetchForTempTargetPresets()
         let allTempTargetPresetsIDs = try await tempTargetsStorage.fetchForTempTargetPresets()
 
 
         // Perform the fetch and process on the Core Data context's thread
         // 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
             // Fetch existing TempTargetStored objects based on their NSManagedObjectIDs
             let tempTargetObjects: [TempTargetStored] = allTempTargetPresetsIDs.compactMap { id in
             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)")
                     debugPrint("\(#file) \(#function) Failed to fetch object for ID: \(id)")
                     return nil
                     return nil
                 }
                 }
@@ -52,12 +55,15 @@ final class TempPresetsIntentRequest: BaseIntentsRequest {
     /// - Parameter uuid: An array of preset IDs to fetch.
     /// - Parameter uuid: An array of preset IDs to fetch.
     /// - Returns: An array of `TempPreset` objects.
     /// - Returns: An array of `TempPreset` objects.
     func fetchIDs(_ uuid: [TempPreset.ID]) async -> [TempPreset] {
     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()
             let fetchRequest: NSFetchRequest<TempTargetStored> = TempTargetStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", uuid)
             fetchRequest.predicate = NSPredicate(format: "id IN %@", uuid)
 
 
             do {
             do {
-                let result = try self.coredataContext.fetch(fetchRequest)
+                let result = try context.fetch(fetchRequest)
 
 
                 if result.isEmpty {
                 if result.isEmpty {
                     debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) No TempTargetStored found for ids: \(uuid)")
                     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.
     /// - Parameter preset: The `TempPreset` to find.
     /// - Returns: The `NSManagedObjectID` of the temp target if found, otherwise `nil`.
     /// - 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()
         let fetchRequest: NSFetchRequest<TempTargetStored> = TempTargetStored.fetchRequest()
         fetchRequest.predicate = NSPredicate(format: "id == %@", preset.id.uuidString)
         fetchRequest.predicate = NSPredicate(format: "id == %@", preset.id.uuidString)
         fetchRequest.fetchLimit = 1
         fetchRequest.fetchLimit = 1
 
 
-        return await coredataContext.perform {
+        return await context.perform {
             do {
             do {
-                return try self.coredataContext.fetch(fetchRequest).first?.objectID
+                return try context.fetch(fetchRequest).first?.objectID
             } catch {
             } catch {
                 debugPrint(
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch Temp Target: \(error)"
                     "\(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)
                                 .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(
                 Section(footer: Text(
                     "Pick a duration to mute every Trio alarm. Critical alerts (e.g. occlusion, urgent low) still pierce the snooze."
                     "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) {
     private func applySnooze(_ duration: TimeInterval) {
         let trioAlertManager = resolver.resolve(TrioAlertManager.self)
         let trioAlertManager = resolver.resolve(TrioAlertManager.self)
         Task { @MainActor in
         Task { @MainActor in
@@ -70,4 +88,12 @@ struct SnoozeAlertsSheetView: View {
             isPresented = false
             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) {
     func assemble(container: Container) {
         // Override PumpHistoryStorage registration for tests
         // Override PumpHistoryStorage registration for tests
         container.register(PumpHistoryStorage.self) { r in
         container.register(PumpHistoryStorage.self) { r in
-            BasePumpHistoryStorage(resolver: r, context: self.testContext)
+            BasePumpHistoryStorage(resolver: r, contextProvider: { self.testContext })
         }.inObjectScope(.container)
         }.inObjectScope(.container)
 
 
         // Override DeterminationStorage registration for tests
         // Override DeterminationStorage registration for tests
         container.register(DeterminationStorage.self) { r in
         container.register(DeterminationStorage.self) { r in
-            BaseDeterminationStorage(resolver: r, context: self.testContext)
+            BaseDeterminationStorage(resolver: r, contextProvider: { self.testContext })
         }.inObjectScope(.container)
         }.inObjectScope(.container)
 
 
         // Override CarbsStorage registration for tests
         // Override CarbsStorage registration for tests
         container.register(CarbsStorage.self) { r in
         container.register(CarbsStorage.self) { r in
-            BaseCarbsStorage(resolver: r, context: self.testContext)
+            BaseCarbsStorage(resolver: r, contextProvider: { self.testContext })
         }.inObjectScope(.container)
         }.inObjectScope(.container)
 
 
         // Override GlucoseStorage registration for tests
         // Override GlucoseStorage registration for tests
         container.register(GlucoseStorage.self) { r in
         container.register(GlucoseStorage.self) { r in
-            BaseGlucoseStorage(resolver: r, context: self.testContext)
+            BaseGlucoseStorage(resolver: r, contextProvider: { self.testContext })
         }.inObjectScope(.container)
         }.inObjectScope(.container)
 
 
         // Override TempTargetStorage registration for tests
         // Override TempTargetStorage registration for tests
         container.register(TempTargetsStorage.self) { r in
         container.register(TempTargetsStorage.self) { r in
-            BaseTempTargetsStorage(resolver: r, context: self.testContext)
+            BaseTempTargetsStorage(resolver: r, contextProvider: { self.testContext })
         }.inObjectScope(.container)
         }.inObjectScope(.container)
 
 
         // Override OverrideStorage registration for tests
         // Override OverrideStorage registration for tests
         container.register(OverrideStorage.self) { r in
         container.register(OverrideStorage.self) { r in
-            BaseOverrideStorage(resolver: r, context: self.testContext)
+            BaseOverrideStorage(resolver: r, contextProvider: { self.testContext })
         }.inObjectScope(.container)
         }.inObjectScope(.container)
     }
     }
 }
 }

+ 13 - 1
TrioTests/OpenAPSSwiftTests/ProfileCarbsTests.swift

@@ -35,11 +35,23 @@ import Testing
         #expect(ratio == 1) // 12 grams per exchange
         #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 {
     @Test("should reject invalid ratios") func rejectInvalidRatios() async throws {
         let invalidSchedule = CarbRatios(
         let invalidSchedule = CarbRatios(
             units: .grams,
             units: .grams,
             schedule: [
             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)
                 CarbRatioEntry(start: "03:00:00", offset: 180, ratio: 15)
             ]
             ]
         )
         )