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

Merge pull request #398 from kingst/oref-swift-merge-dev

Merge the latest dev into oref-swift
Deniz Cengiz 1 год назад
Родитель
Сommit
66ed905a88
100 измененных файлов с 200666 добавлено и 8471 удалено
  1. 1 3
      .gitignore
  2. 1 1
      G7SensorKit
  3. 1 1
      LibreTransmitter
  4. 1 1
      LiveActivity/Views/WidgetItems/LiveActivityCOBLabelView.swift
  5. 1 1
      LiveActivity/Views/WidgetItems/LiveActivityIOBLabelView.swift
  6. 1 1
      LiveActivity/Views/WidgetItems/LiveActivityTotalDailyDoseView.swift
  7. 0 1
      Model/Classes+Properties/OrefDetermination+CoreDataProperties.swift
  8. 43 0
      Model/CoreDataInitializationCoordinator.swift
  9. 90 16
      Model/CoreDataObserver.swift
  10. 209 92
      Model/CoreDataStack.swift
  11. 5 0
      Model/Helper/CarbEntryStored+helper.swift
  12. 24 21
      Model/Helper/CoreDataError.swift
  13. 5 0
      Model/Helper/Determination+helper.swift
  14. 21 0
      Model/Helper/GlucoseStored+helper.swift
  15. 36 1
      Model/Helper/PumpEvent+helper.swift
  16. 11 0
      Model/Helper/TempTargetStored+Helper.swift
  17. 16 2
      Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents
  18. 1 1
      OmniKit
  19. 1 1
      RileyLinkKit
  20. 4 0
      TDDStored+CoreDataClass.swift
  21. 18 0
      TDDStored+CoreDataProperties.swift
  22. 11 0
      Trio Watch App Extension/Views/AcknowledgementPendingView.swift
  23. 1 1
      Trio Watch App Extension/Views/BolusConfirmationView.swift
  24. 9 3
      Trio Watch App Extension/Views/BolusInputView.swift
  25. 4 1
      Trio Watch App Extension/Views/BolusProgressOverlay.swift
  26. 3 2
      Trio Watch App Extension/Views/CarbsInputView.swift
  27. 8 3
      Trio Watch App Extension/Views/GlucoseTrendView.swift
  28. 3 3
      Trio Watch App Extension/Views/TreatmentMenuView.swift
  29. 4 5
      Trio Watch App Extension/WatchState.swift
  30. 281 174
      Trio.xcodeproj/project.pbxproj
  31. 54 0
      Trio.xcodeproj/xcshareddata/xcschemes/Trio Tests.xcscheme
  32. 1 1
      Trio.xcodeproj/xcshareddata/xcschemes/Trio Watch App.xcscheme
  33. 1 1
      Trio.xcodeproj/xcshareddata/xcschemes/Trio Watch Complication Extension.xcscheme
  34. 2 2
      Trio.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme
  35. BIN
      Trio/Resources/Assets.xcassets/app_icon_images/trioCircledNoBackground.imageset/ComplicationIcon.png
  36. 12 0
      Trio/Resources/Assets.xcassets/app_icon_images/trioCircledNoBackground.imageset/Contents.json
  37. BIN
      Trio/Resources/Assets.xcassets/app_icons/trioCircledNoBackground.imageset/ComplicationIcon.png
  38. 12 0
      Trio/Resources/Assets.xcassets/app_icons/trioCircledNoBackground.imageset/Contents.json
  39. 0 23
      Trio/Resources/Base.lproj/InfoPlist.strings
  40. 8 4
      Trio/Resources/Info.plist
  41. 1059 0
      Trio/Resources/InfoPlist.xcstrings
  42. 0 20
      Trio/Resources/ar.lproj/InfoPlist.strings
  43. 0 20
      Trio/Resources/ca.lproj/InfoPlist.strings
  44. 0 20
      Trio/Resources/da.lproj/InfoPlist.strings
  45. 0 20
      Trio/Resources/de.lproj/InfoPlist.strings
  46. 0 20
      Trio/Resources/es.lproj/InfoPlist.strings
  47. 0 20
      Trio/Resources/fi.lproj/InfoPlist.strings
  48. 0 20
      Trio/Resources/fr.lproj/InfoPlist.strings
  49. 0 20
      Trio/Resources/he.lproj/InfoPlist.strings
  50. 0 20
      Trio/Resources/hu.lproj/InfoPlist.strings
  51. 0 20
      Trio/Resources/it.lproj/InfoPlist.strings
  52. 1 1
      Trio/Resources/javascript/bundle/determine-basal.js
  53. 1 3
      Trio/Resources/json/defaults/freeaps/freeaps_settings.json
  54. 0 20
      Trio/Resources/nb.lproj/InfoPlist.strings
  55. 0 20
      Trio/Resources/nl.lproj/InfoPlist.strings
  56. 0 20
      Trio/Resources/pl.lproj/InfoPlist.strings
  57. 0 20
      Trio/Resources/pt-BR.lproj/InfoPlist.strings
  58. 0 20
      Trio/Resources/pt-PT.lproj/InfoPlist.strings
  59. 0 20
      Trio/Resources/ru.lproj/InfoPlist.strings
  60. 0 20
      Trio/Resources/sk.lproj/InfoPlist.strings
  61. 0 20
      Trio/Resources/sv.lproj/InfoPlist.strings
  62. 0 20
      Trio/Resources/tr.lproj/InfoPlist.strings
  63. 0 20
      Trio/Resources/uk.lproj/InfoPlist.strings
  64. 0 20
      Trio/Resources/vi.lproj/InfoPlist.strings
  65. 0 20
      Trio/Resources/zh-Hans.lproj/InfoPlist.strings
  66. 361 364
      Trio/Sources/APS/APSManager.swift
  67. 1 1
      Trio/Sources/APS/CGM/AppGroupSource.swift
  68. 8 7
      Trio/Sources/APS/CGM/CGMType.swift
  69. 185 90
      Trio/Sources/APS/CGM/GlucoseSimulatorSource.swift
  70. 64 31
      Trio/Sources/APS/CGM/PluginSource.swift
  71. 15 9
      Trio/Sources/APS/DeviceDataManager.swift
  72. 67 99
      Trio/Sources/APS/FetchGlucoseManager.swift
  73. 39 35
      Trio/Sources/APS/FetchTreatmentsManager.swift
  74. 55 47
      Trio/Sources/APS/OpenAPS/OpenAPS.swift
  75. 51 49
      Trio/Sources/APS/Storage/CarbsStorage.swift
  76. 34 27
      Trio/Sources/APS/Storage/ContactImageStorage.swift
  77. 51 18
      Trio/Sources/APS/Storage/DeterminationStorage.swift
  78. 249 177
      Trio/Sources/APS/Storage/GlucoseStorage.swift
  79. 157 62
      Trio/Sources/APS/Storage/OverrideStorage.swift
  80. 224 203
      Trio/Sources/APS/Storage/PumpHistoryStorage.swift
  81. 661 0
      Trio/Sources/APS/Storage/TDDStorage.swift
  82. 85 53
      Trio/Sources/APS/Storage/TempTargetsStorage.swift
  83. 20 6
      Trio/Sources/Application/AppDelegate.swift
  84. 118 39
      Trio/Sources/Application/TrioApp.swift
  85. 2 2
      Trio/Sources/Assemblies/ServiceAssembly.swift
  86. 1 0
      Trio/Sources/Assemblies/StorageAssembly.swift
  87. 1 1
      Trio/Sources/Config/Config.swift
  88. 29 0
      Trio/Sources/Helpers/BackgroundTask+Helper.swift
  89. 20 11
      Trio/Sources/Helpers/BuildDetails.swift
  90. 19 0
      Trio/Sources/Helpers/Calendar+GlucoseStatsChart.swift
  91. 1 1
      Trio/Sources/Helpers/CustomProgressView.swift
  92. 14 0
      Trio/Sources/Helpers/Formatters.swift
  93. 4 4
      Trio/Sources/Helpers/HKUnit.swift
  94. 1 1
      Trio/Sources/Helpers/MainChartHelper.swift
  95. 21 0
      Trio/Sources/Helpers/TherapySettingsUtil.swift
  96. 196143 0
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  97. 0 2223
      Trio/Sources/Localizations/Main/ar.lproj/Localizable.strings
  98. 0 1917
      Trio/Sources/Localizations/Main/ca.lproj/Localizable.strings
  99. 0 2184
      Trio/Sources/Localizations/Main/da.lproj/Localizable.strings
  100. 0 0
      Trio/Sources/Localizations/Main/de.lproj/Localizable.strings

+ 1 - 3
.gitignore

@@ -79,6 +79,4 @@ fastlane/screenshots
 fastlane/test_output
 fastlane/FastlaneRunner
 
-ConfigOverride.xcconfig
-
-branch.txt
+ConfigOverride.xcconfig

+ 1 - 1
G7SensorKit

@@ -1 +1 @@
-Subproject commit 2be3eb29b0a18aa89f8b60281341e46e07d024e5
+Subproject commit bdfcfe83fbb9fab515a2456a4be9991420ed44bb

+ 1 - 1
LibreTransmitter

@@ -1 +1 @@
-Subproject commit a230b91a3d30c7b0d4ffbd240234b34cbaf354b1
+Subproject commit 044cf70bd79813d47048291b740a599e1ab4ab40

+ 1 - 1
LiveActivity/Views/WidgetItems/LiveActivityCOBLabelView.swift

@@ -22,7 +22,7 @@ struct LiveActivityCOBLabelView: View {
                     .foregroundStyle(context.isStale ? .secondary : .primary)
                     .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
 
-                Text("g")
+                Text(String(localized: "g", comment: "gram of carbs"))
                     .font(.headline).fontWeight(.bold)
                     .foregroundStyle(context.isStale ? .secondary : .primary)
                     .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))

+ 1 - 1
LiveActivity/Views/WidgetItems/LiveActivityIOBLabelView.swift

@@ -30,7 +30,7 @@ struct LiveActivityIOBLabelView: View {
                 .foregroundStyle(context.isStale ? .secondary : .primary)
                 .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
 
-                Text("U")
+                Text(String(localized: "U", comment: "Insulin unit"))
                     .font(.headline).fontWeight(.bold)
                     .foregroundStyle(context.isStale ? .secondary : .primary)
                     .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))

+ 1 - 1
LiveActivity/Views/WidgetItems/LiveActivityTotalDailyDoseView.swift

@@ -24,7 +24,7 @@ struct LiveActivityTotalDailyDoseView: View {
                 .foregroundStyle(context.isStale ? .secondary : .primary)
                 .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
 
-                Text("U")
+                Text(String(localized: "U", comment: "Insulin unit"))
                     .font(.headline).fontWeight(.bold)
                     .foregroundStyle(context.isStale ? .secondary : .primary)
                     .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))

+ 0 - 1
Model/Classes+Properties/OrefDetermination+CoreDataProperties.swift

@@ -37,7 +37,6 @@ public extension OrefDetermination {
     @NSManaged var threshold: NSDecimalNumber?
     @NSManaged var timestamp: Date?
     @NSManaged var timestampEnacted: Date?
-    @NSManaged var totalDailyDose: NSDecimalNumber?
     @NSManaged var forecasts: Set<Forecast>?
 }
 

+ 43 - 0
Model/CoreDataInitializationCoordinator.swift

@@ -0,0 +1,43 @@
+/// This actor provides us with logic to handle cases when a caller
+/// tries to initialize a coreDataStack that is already initialized.
+actor CoreDataInitializationCoordinator {
+    private var isInitialized = false
+    private var initializationTask: Task<Void, Error>?
+
+    /// Ensures that initialization only happens once and manages multiple concurrent initialization requests.
+    /// This actor provides synchronization for the CoreDataStack initialization process.
+    ///
+    /// - Parameters:
+    ///   - initialization: A closure that performs the actual initialization work.
+    /// - Throws: Any error that might occur during initialization.
+    /// - Returns: Void once initialization is complete.
+    func ensureInitialized(perform initialization: @escaping () async throws -> Void) async throws {
+        // If already initialized, return immediately
+        if isInitialized {
+            return
+        }
+
+        // If initialization is in progress, await the existing task
+        if let existingTask = initializationTask {
+            try await existingTask.value
+            return
+        }
+
+        // Start a new initialization task
+        let newTask = Task {
+            do {
+                try await initialization()
+                isInitialized = true
+            } catch {
+                // Clear task reference on failure
+                initializationTask = nil
+                throw error
+            }
+            // Clear task reference on success
+            initializationTask = nil
+        }
+
+        initializationTask = newTask
+        try await newTask.value
+    }
+}

+ 90 - 16
Model/CoreDataObserver.swift

@@ -2,34 +2,108 @@ import Combine
 import CoreData
 import Foundation
 
-func changedObjectsOnManagedObjectContextDidSavePublisher() -> some Publisher<Set<NSManagedObjectID>, Never> {
+/// 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: NSNotification.Name.NSManagedObjectContextDidSave)
-        .map { notification in
-            guard let userInfo = notification.userInfo else { return Set<NSManagedObjectID>() }
+        .publisher(for: .NSManagedObjectContextDidSave)
+        .compactMap { notification -> Set<NSManagedObjectID>? in
 
             var objectIDs = Set<NSManagedObjectID>()
 
-            if let inserted = userInfo[NSInsertedObjectsKey] as? Set<NSManagedObject> {
-                objectIDs.formUnion(inserted.map(\.objectID))
+            // Process inserted objects if requested
+            if changeTypes.contains(.inserted) {
+                objectIDs.formUnion(notification.insertedObjectIDs)
             }
-            if let updated = userInfo[NSUpdatedObjectsKey] as? Set<NSManagedObject> {
-                objectIDs.formUnion(updated.map(\.objectID))
+
+            // Process updated objects if requested
+            if changeTypes.contains(.updated) {
+                objectIDs.formUnion(notification.updatedObjectIDs)
             }
-            if let deleted = userInfo[NSDeletedObjectsKey] as? Set<NSManagedObject> {
-                objectIDs.formUnion(deleted.map(\.objectID))
+
+            // Process deleted objects if requested
+            if changeTypes.contains(.deleted) {
+                objectIDs.formUnion(notification.deletedObjectIDs)
             }
 
-            return objectIDs
+            // Only emit non-empty sets
+            return objectIDs.isEmpty ? nil : objectIDs
         }
 }
 
 extension Publisher where Output == Set<NSManagedObjectID> {
-    func filterByEntityName(_ name: String) -> some Publisher<Self.Output, Self.Failure> {
-        filter { objectIDs in
-            objectIDs.contains { objectID in
-                objectID.entity.name == name
-            }
+    /// Filters Core Data changes by entity name
+    ///
+    /// This method allows filtering Core Data changes by entity name.
+    ///
+    /// Example usage:
+    /// ```swift
+    /// // Filter changes for "GlucoseStored" entity
+    /// publisher.filteredByEntityName("GlucoseStored")
+    /// ```
+    ///
+    /// - Parameters:
+    ///   - name: The name of the Core Data entity to filter for
+    /// - Returns: A publisher emitting filtered sets of NSManagedObjectIDs
+    func filteredByEntityName(
+        _ name: String
+    ) -> some Publisher<Set<NSManagedObjectID>, Self.Failure> {
+        compactMap { objectIDs -> Set<NSManagedObjectID>? in
+            // Early exit for empty sets
+            guard !objectIDs.isEmpty else { return nil }
+
+            // Use lazy evaluation for better performance
+            let filtered = objectIDs.lazy.filter { $0.entity.name == name }
+            let result = Set(filtered)
+            return result.isEmpty ? nil : result
         }
     }
 }
+
+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))
+    }
+}

+ 209 - 92
Model/CoreDataStack.swift

@@ -9,19 +9,47 @@ class CoreDataStack: ObservableObject {
     private var notificationToken: NSObjectProtocol?
     private let inMemory: Bool
 
+    let persistentContainer: NSPersistentContainer
+
+    private let maxRetries = 3
+    private let initializationCoordinator = CoreDataInitializationCoordinator()
+
     private init(inMemory: Bool = false) {
         self.inMemory = inMemory
 
-        // Observe Core Data remote change notifications on the queue where the changes were made
-        notificationToken = Foundation.NotificationCenter.default.addObserver(
-            forName: .NSPersistentStoreRemoteChange,
-            object: nil,
-            queue: nil
-        ) { _ in
-            Task {
-                await self.fetchPersistentHistory()
-            }
+        // Initialize persistent container immediately
+        persistentContainer = NSPersistentContainer(
+            name: "TrioCoreDataPersistentContainer",
+            managedObjectModel: Self.managedObjectModel
+        )
+
+        guard let description = persistentContainer.persistentStoreDescriptions.first else {
+            fatalError("Failed \(DebuggingIdentifiers.failed) to retrieve a persistent store description")
+        }
+
+        if inMemory {
+            description.url = URL(fileURLWithPath: "/dev/null")
         }
+
+        // Enable persistent store remote change notifications
+        /// - Tag: persistentStoreRemoteChange
+        description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
+
+        // Enable persistent history tracking
+        /// - Tag: persistentHistoryTracking
+        description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
+
+        // Enable lightweight migration
+        /// - Tag: lightweightMigration
+        description.shouldMigrateStoreAutomatically = true
+        description.shouldInferMappingModelAutomatically = true
+
+        persistentContainer.viewContext.automaticallyMergesChangesFromParent = false
+        persistentContainer.viewContext.name = "viewContext"
+        /// - Tag: viewContextmergePolicy
+        persistentContainer.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
+        persistentContainer.viewContext.undoManager = nil
+        persistentContainer.viewContext.shouldDeleteInaccessibleFaults = true
     }
 
     deinit {
@@ -41,44 +69,32 @@ class CoreDataStack: ObservableObject {
         }
     }
 
-    /// A persistent container to set up the Core Data Stack
-    lazy var persistentContainer: NSPersistentContainer = {
-        let container = NSPersistentContainer(name: "TrioCoreDataPersistentContainer")
+    // Factory method for tests
+    static func createForTests() async throws -> CoreDataStack {
+        let stack = CoreDataStack(inMemory: true)
+        try await stack.initializeStack()
+        return stack
+    }
 
-        guard let description = container.persistentStoreDescriptions.first else {
-            fatalError("Failed \(DebuggingIdentifiers.failed) to retrieve a persistent store description")
-        }
+    // Used for Canvas Preview
+    static func preview() async throws -> CoreDataStack {
+        let stack = CoreDataStack(inMemory: true)
+        try await stack.initializeStack()
+        return stack
+    }
 
-        if inMemory {
-            description.url = URL(fileURLWithPath: "/dev/null")
+    // Shared managed object model
+    static var managedObjectModel: NSManagedObjectModel = {
+        let bundle = Bundle(for: CoreDataStack.self)
+        guard let url = bundle.url(forResource: "TrioCoreDataPersistentContainer", withExtension: "momd") else {
+            fatalError("Failed \(DebuggingIdentifiers.failed) to locate momd file")
         }
 
-        // Enable persistent store remote change notifications
-        /// - Tag: persistentStoreRemoteChange
-        description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
-
-        // Enable persistent history tracking
-        /// - Tag: persistentHistoryTracking
-        description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
-
-        // Enable lightweight migration
-        /// - Tag: lightweightMigration
-        description.shouldMigrateStoreAutomatically = true
-        description.shouldInferMappingModelAutomatically = true
-
-        container.loadPersistentStores { _, error in
-            if let error = error as NSError? {
-                fatalError("Unresolved Error \(DebuggingIdentifiers.failed) \(error), \(error.userInfo)")
-            }
+        guard let model = NSManagedObjectModel(contentsOf: url) else {
+            fatalError("Failed \(DebuggingIdentifiers.failed) to load momd file")
         }
 
-        container.viewContext.automaticallyMergesChangesFromParent = false
-        container.viewContext.name = "viewContext"
-        /// - Tag: viewContextmergePolicy
-        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
-        container.viewContext.undoManager = nil
-        container.viewContext.shouldDeleteInaccessibleFaults = true
-        return container
+        return model
     }()
 
     /// Creates and configures a private queue context
@@ -98,14 +114,14 @@ class CoreDataStack: ObservableObject {
         do {
             try await fetchPersistentHistoryTransactionsAndChanges()
         } catch {
-            debugPrint("\(error.localizedDescription)")
+            debug(.coreData, "\(error.localizedDescription)")
         }
     }
 
     private func fetchPersistentHistoryTransactionsAndChanges() async throws {
         let taskContext = newTaskContext()
         taskContext.name = "persistentHistoryContext"
-        //        debugPrint("Start fetching persistent history changes from the store ... \(DebuggingIdentifiers.inProgress)")
+//        debug(.coreData,"Start fetching persistent history changes from the store ... \(DebuggingIdentifiers.inProgress)")
 
         try await taskContext.perform {
             // Execute the persistent history change since the last transaction
@@ -120,7 +136,7 @@ class CoreDataStack: ObservableObject {
     }
 
     private func mergePersistentHistoryChanges(from history: [NSPersistentHistoryTransaction]) {
-        //        debugPrint("Received \(history.count) persistent history transactions")
+//        debug(.coreData,"Received \(history.count) persistent history transactions")
         // Update view context with objectIDs from history change request
         /// - Tag: mergeChanges
         let viewContext = persistentContainer.viewContext
@@ -142,22 +158,106 @@ class CoreDataStack: ObservableObject {
             let deleteHistoryTokensRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: date)
             do {
                 try taskContext.execute(deleteHistoryTokensRequest)
-                debugPrint("\(DebuggingIdentifiers.succeeded) Successfully deleted persistent history before \(date)")
+                debug(.coreData, "\(DebuggingIdentifiers.succeeded) Successfully deleted persistent history from before \(date)")
             } catch {
-                debugPrint(
-                    "\(DebuggingIdentifiers.failed) Failed to delete persistent history before \(date): \(error.localizedDescription)"
+                debug(
+                    .coreData,
+                    "\(DebuggingIdentifiers.failed) Failed to delete persistent history from before \(date): \(error.localizedDescription)"
                 )
             }
         }
     }
 
-    func initializeStack() throws {
-        // Force initialization of persistent container
-        let container = persistentContainer
+    private func setupPersistentStoreChangeNotifications() {
+        // Observe Core Data remote change notifications on the queue where the changes were made
+        notificationToken = Foundation.NotificationCenter.default.addObserver(
+            forName: .NSPersistentStoreRemoteChange,
+            object: nil,
+            queue: nil
+        ) { _ in
+            Task {
+                await self.fetchPersistentHistory()
+            }
+        }
+
+        debug(.coreData, "Set up persistent store change notifications")
+    }
+
+    /// Loads the persistent stores asynchronously.
+    ///
+    /// Converts the synchronous NSPersistentContainer loading process into an async/await compatible
+    /// function using a continuation.
+    ///
+    /// - Throws: Any errors encountered during the loading of persistent stores.
+    /// - Returns: Void once stores are loaded successfully
+    private func loadPersistentStores() async throws {
+        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
+            persistentContainer.loadPersistentStores { storeDescription, error in
+                if let error = error {
+                    warning(.coreData, "Failed to load persistent stores: \(error.localizedDescription)")
+                    continuation.resume(throwing: error)
+                } else {
+                    debug(.coreData, "Successfully loaded persistent store: \(storeDescription.url?.absoluteString ?? "unknown")")
+                    continuation.resume(returning: ())
+                }
+            }
+        }
+    }
+
+    /// Public entry point for initializing the CoreData stack.
+    ///
+    /// Uses the initialization coordinator to ensure initialization happens only once,
+    /// even with concurrent calls. Subsequent calls will wait for the original initialization
+    /// to complete.
+    ///
+    /// - Throws: Any errors that occur during initialization.
+    /// - Returns: Void once initialization is complete.
+    func initializeStack() async throws {
+        try await initializationCoordinator.ensureInitialized {
+            try await self.initializeStack(retryCount: 0)
+        }
+    }
+
+    /// Private implementation of the initialization process with retry capability.
+    ///
+    /// Handles the actual initialization work including store loading, verification,
+    /// notification setup, and error handling with retry logic.
+    ///
+    /// - Parameter retryCount: The current retry attempt number, starting at 0.
+    /// - Throws: CoreDataError or any other error if initialization fails after all retries.
+    /// - Returns: Void when initialization completes successfully.
+    private func initializeStack(retryCount: Int) async throws {
+        do {
+            // Load stores asynchronously
+            try await loadPersistentStores()
+
+            // Verify the store is loaded
+            guard persistentContainer.persistentStoreCoordinator.persistentStores.isEmpty == false else {
+                let error = CoreDataError.storeNotInitializedError(function: #function, file: #file)
+                throw error
+            }
+
+            setupPersistentStoreChangeNotifications()
+
+            debug(.coreData, "Core Data stack initialized successfully")
+
+        } catch {
+            debug(.coreData, "Failed to initialize Core Data stack: \(error.localizedDescription)")
+
+            // If we still have retries left, try again after a delay
+            if retryCount < maxRetries {
+                debug(.coreData, "Retrying initialization (\(retryCount + 1)/\(maxRetries))")
+
+                // Wait before retrying
+                try await Task.sleep(for: .seconds(1))
 
-        // Verify the store is loaded and available
-        guard container.persistentStoreCoordinator.persistentStores.isNotEmpty else {
-            throw CoreDataError.storeNotInitializedError
+                // Retry the initialization
+                try await initializeStack(retryCount: retryCount + 1)
+            } else {
+                // We've exhausted our retries
+                debug(.coreData, "Core Data initialization failed after \(maxRetries) attempts")
+                throw error
+            }
         }
     }
 }
@@ -169,7 +269,7 @@ extension CoreDataStack {
     ///  - Tag: synchronousDelete
     func deleteObject(identifiedBy objectID: NSManagedObjectID) async {
         let viewContext = persistentContainer.viewContext
-        debugPrint("Start deleting data from the store ...\(DebuggingIdentifiers.inProgress)")
+        debug(.coreData, "Start deleting data from the store ...\(DebuggingIdentifiers.inProgress)")
 
         await viewContext.perform {
             do {
@@ -178,9 +278,9 @@ extension CoreDataStack {
 
                 guard viewContext.hasChanges else { return }
                 try viewContext.save()
-                debugPrint("Successfully deleted data. \(DebuggingIdentifiers.succeeded)")
+                debug(.coreData, "Successfully deleted data. \(DebuggingIdentifiers.succeeded)")
             } catch {
-                debugPrint("Failed to delete data: \(error.localizedDescription)")
+                debug(.coreData, "Failed to delete data: \(error.localizedDescription)")
             }
         }
     }
@@ -191,7 +291,9 @@ extension CoreDataStack {
         _ objectType: T.Type,
         dateKey: String,
         days: Int,
-        isPresetKey: String? = nil
+        isPresetKey: String? = nil,
+        callingFunction: String = #function,
+        callingClass: String = #fileID
     ) async throws {
         let taskContext = newTaskContext()
         taskContext.name = "deleteContext"
@@ -219,7 +321,7 @@ extension CoreDataStack {
 
             // Guard check if there are NSManagedObjects older than the specified days
             guard !objectIDs.isEmpty else {
-//                debugPrint("No objects found older than \(days) days.")
+//                debug(.coreData,"No objects found older than \(days) days.")
                 return
             }
 
@@ -230,15 +332,15 @@ extension CoreDataStack {
                       let batchDeleteResult = fetchResult as? NSBatchDeleteResult,
                       let success = batchDeleteResult.result as? Bool, success
                 else {
-                    debugPrint("Failed to execute batch delete request \(DebuggingIdentifiers.failed)")
-                    throw CoreDataError.batchDeleteError
+                    debug(.coreData, "Failed to execute batch delete request \(DebuggingIdentifiers.failed)")
+                    throw CoreDataError.batchDeleteError(function: callingFunction, file: callingClass)
                 }
             }
 
-            debugPrint("Successfully deleted data older than \(days) days. \(DebuggingIdentifiers.succeeded)")
+            debug(.coreData, "Successfully deleted data older than \(days) days. \(DebuggingIdentifiers.succeeded)")
         } catch {
-            debugPrint("Failed to fetch or delete data: \(error.localizedDescription) \(DebuggingIdentifiers.failed)")
-            throw CoreDataError.batchDeleteError
+            debug(.coreData, "Failed to fetch or delete data: \(error.localizedDescription) \(DebuggingIdentifiers.failed)")
+            throw CoreDataError.unexpectedError(error: error, function: callingFunction, file: callingClass)
         }
     }
 
@@ -247,7 +349,9 @@ extension CoreDataStack {
         childType: Child.Type,
         dateKey: String,
         days: Int,
-        relationshipKey: String // The key of the Child Entity that links to the parent Entity
+        relationshipKey: String, // The key of the Child Entity that links to the parent Entity
+        callingFunction: String = #function,
+        callingClass: String = #fileID
     ) async throws {
         let taskContext = newTaskContext()
         taskContext.name = "deleteContext"
@@ -267,7 +371,7 @@ extension CoreDataStack {
             }
 
             guard !parentObjectIDs.isEmpty else {
-//                debugPrint("No \(parentType) objects found older than \(days) days.")
+//                debug(.coreData,"No \(parentType) objects found older than \(days) days.")
                 return
             }
 
@@ -281,7 +385,7 @@ extension CoreDataStack {
             }
 
             guard !childObjectIDs.isEmpty else {
-//                debugPrint("No \(childType) objects found related to \(parentType) objects older than \(days) days.")
+//                debug(.coreData,"No \(childType) objects found related to \(parentType) objects older than \(days) days.")
                 return
             }
 
@@ -292,17 +396,18 @@ extension CoreDataStack {
                       let batchDeleteResult = fetchResult as? NSBatchDeleteResult,
                       let success = batchDeleteResult.result as? Bool, success
                 else {
-                    debugPrint("Failed to execute batch delete request \(DebuggingIdentifiers.failed)")
-                    throw CoreDataError.batchDeleteError
+                    debug(.coreData, "Failed to execute batch delete request \(DebuggingIdentifiers.failed)")
+                    throw CoreDataError.batchDeleteError(function: callingFunction, file: callingClass)
                 }
             }
 
-            debugPrint(
+            debug(
+                .coreData,
                 "Successfully deleted \(childType) data related to \(parentType) objects older than \(days) days. \(DebuggingIdentifiers.succeeded)"
             )
         } catch {
-            debugPrint("Failed to fetch or delete data: \(error.localizedDescription) \(DebuggingIdentifiers.failed)")
-            throw CoreDataError.batchDeleteError
+            debug(.coreData, "Failed to fetch or delete data: \(error.localizedDescription) \(DebuggingIdentifiers.failed)")
+            throw CoreDataError.unexpectedError(error: error, function: callingFunction, file: callingClass)
         }
     }
 }
@@ -323,7 +428,7 @@ extension CoreDataStack {
         propertiesToFetch: [String]? = nil,
         callingFunction: String = #function,
         callingClass: String = #fileID
-    ) -> [Any] {
+    ) throws -> [Any] {
         let request = NSFetchRequest<NSFetchRequestResult>(entityName: String(describing: type))
         request.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)]
         request.predicate = predicate
@@ -344,7 +449,7 @@ extension CoreDataStack {
         context.transactionAuthor = "fetchEntities"
 
         /// we need to ensure that the fetch immediately returns a value as long as the whole app does not use the async await pattern, otherwise we could perform this asynchronously with backgroundContext.perform and not block the thread
-        return context.performAndWait {
+        return try context.performAndWait {
             do {
                 if propertiesToFetch != nil {
                     return try context.fetch(request) as? [[String: Any]] ?? []
@@ -352,11 +457,10 @@ extension CoreDataStack {
                     return try context.fetch(request) as? [T] ?? []
                 }
             } catch let error as NSError {
-                debugPrint(
-                    "Fetching \(T.self) in \(callingFunction) from \(callingClass): \(DebuggingIdentifiers.failed) \(error) on Thread: \(Thread.current)"
+                throw CoreDataError.fetchError(
+                    function: callingFunction,
+                    file: callingClass
                 )
-
-                return []
             }
         }
     }
@@ -371,12 +475,14 @@ extension CoreDataStack {
         fetchLimit: Int? = nil,
         batchSize: Int? = nil,
         propertiesToFetch: [String]? = nil,
+        relationshipKeyPathsForPrefetching: [String]? = nil,
         callingFunction: String = #function,
         callingClass: String = #fileID
-    ) async -> Any {
+    ) async throws -> Any {
         let request: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: String(describing: type))
         request.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)]
         request.predicate = predicate
+
         if let limit = fetchLimit {
             request.fetchLimit = limit
         }
@@ -389,11 +495,14 @@ extension CoreDataStack {
         } else {
             request.resultType = .managedObjectResultType
         }
+        if let prefetchKeyPaths = relationshipKeyPathsForPrefetching {
+            request.relationshipKeyPathsForPrefetching = prefetchKeyPaths
+        }
 
         context.name = "fetchContext"
         context.transactionAuthor = "fetchEntities"
 
-        return await context.perform {
+        return try await context.perform {
             do {
                 if propertiesToFetch != nil {
                     return try context.fetch(request) as? [[String: Any]] ?? []
@@ -401,10 +510,11 @@ extension CoreDataStack {
                     return try context.fetch(request) as? [T] ?? []
                 }
             } catch let error as NSError {
-                debugPrint(
-                    "Fetching \(T.self) in \(callingFunction) from \(callingClass): \(DebuggingIdentifiers.failed) \(error) on Thread: \(Thread.current)"
+                throw CoreDataError.unexpectedError(
+                    error: error,
+                    function: callingFunction,
+                    file: callingClass
                 )
-                return []
             }
         }
     }
@@ -412,9 +522,11 @@ extension CoreDataStack {
     // Get NSManagedObject
     func getNSManagedObject<T: NSManagedObject>(
         with ids: [NSManagedObjectID],
-        context: NSManagedObjectContext
-    ) async -> [T] {
-        await context.perform {
+        context: NSManagedObjectContext,
+        callingFunction: String = #function,
+        callingClass: String = #fileID
+    ) async throws -> [T] {
+        try await context.perform {
             var objects = [T]()
             do {
                 for id in ids {
@@ -422,10 +534,13 @@ extension CoreDataStack {
                         objects.append(object)
                     }
                 }
+                return objects
             } catch {
-                debugPrint("Failed to fetch objects: \(error.localizedDescription)")
+                throw CoreDataError.fetchError(
+                    function: callingFunction,
+                    file: callingClass
+                )
             }
-            return objects
         }
     }
 }
@@ -442,7 +557,7 @@ extension CoreDataStack {
         do {
             try context.save()
         } catch {
-            debugPrint("Error saving context \(DebuggingIdentifiers.failed): \(error)")
+            debug(.coreData, "Error saving context \(DebuggingIdentifiers.failed): \(error)")
         }
     }
 }
@@ -458,11 +573,13 @@ extension NSManagedObjectContext {
         do {
             guard onContext.hasChanges else { return }
             try onContext.save()
-//            debugPrint(
-//                "Saving to Core Data successful in \(callingFunction) in \(callingClass): \(DebuggingIdentifiers.succeeded)"
-//            )
+            debug(
+                .coreData,
+                "Saving to Core Data successful in \(callingFunction) in \(callingClass): \(DebuggingIdentifiers.succeeded)"
+            )
         } catch let error as NSError {
-            debugPrint(
+            debug(
+                .coreData,
                 "Saving to Core Data failed in \(callingFunction) in \(callingClass): \(DebuggingIdentifiers.failed) with error \(error), \(error.userInfo)"
             )
             throw error

+ 5 - 0
Model/Helper/CarbEntryStored+helper.swift

@@ -12,6 +12,11 @@ extension NSPredicate {
         return NSPredicate(format: "isFPU == false AND date >= %@ AND carbs > 0", date as NSDate)
     }
 
+    static var carbsForStats: NSPredicate {
+        let date = Date.threeMonthsAgo
+        return NSPredicate(format: "date >= %@", date as NSDate)
+    }
+
     static var carbsNotYetUploadedToNightscout: NSPredicate {
         let date = Date.oneDayAgo
         return NSPredicate(

+ 24 - 21
Model/Helper/CoreDataError.swift

@@ -1,32 +1,35 @@
 import Foundation
 
 enum CoreDataError: Error {
-    case creationError
-    case batchInsertError
-    case batchDeleteError
-    case persistentHistoryChangeError
-    case unexpectedError(error: Error)
-    case fetchError
-    case storeNotInitializedError
+    case validationError(function: String, file: String)
+    case creationError(function: String, file: String)
+    case batchInsertError(function: String, file: String)
+    case batchDeleteError(function: String, file: String)
+    case persistentHistoryChangeError(function: String, file: String)
+    case unexpectedError(error: Error, function: String, file: String)
+    case fetchError(function: String, file: String)
+    case storeNotInitializedError(function: String, file: String)
 }
 
 extension CoreDataError: LocalizedError {
     var errorDescription: String? {
         switch self {
-        case .creationError:
-            return NSLocalizedString("Failed to create a new object.", comment: "")
-        case .batchInsertError:
-            return NSLocalizedString("Failed to execute a batch insert request.", comment: "")
-        case .batchDeleteError:
-            return NSLocalizedString("Failed to execute a batch delete request.", comment: "")
-        case .persistentHistoryChangeError:
-            return NSLocalizedString("Failed to execute a persistent history change request.", comment: "")
-        case let .unexpectedError(error):
-            return NSLocalizedString("Received unexpected error. \(error.localizedDescription)", comment: "")
-        case .fetchError:
-            return NSLocalizedString("Failed to fetch object \(DebuggingIdentifiers.failed).", comment: "")
-        case .storeNotInitializedError:
-            return NSLocalizedString("Failed to initialize Core Data's persistent store.", comment: "")
+        case let .creationError(function, file):
+            return String(localized: "Failed to create a new object in \(function) from \(file).")
+        case let .batchInsertError(function, file):
+            return String(localized: "Failed to execute a batch insert request in \(function) from \(file).")
+        case let .batchDeleteError(function, file):
+            return String(localized: "Failed to execute a batch delete request in \(function) from \(file).")
+        case let .persistentHistoryChangeError(function, file):
+            return String(localized: "Failed to execute a persistent history change request in \(function) from \(file).")
+        case let .unexpectedError(error, function, file):
+            return String(localized: "Received unexpected error in \(function) from \(file): \(error.localizedDescription)")
+        case let .fetchError(function, file):
+            return String(localized: "Failed to fetch object \(DebuggingIdentifiers.failed) in \(function) from \(file).")
+        case let .validationError(function, file):
+            return String(localized: "Failed to validate object in \(function) from \(file).")
+        case let .storeNotInitializedError(function, file):
+            return String(localized: "Store not initialized in \(function) from \(file).")
         }
     }
 }

+ 5 - 0
Model/Helper/Determination+helper.swift

@@ -50,4 +50,9 @@ extension NSPredicate {
             true as NSNumber
         )
     }
+
+    static var determinationsForStats: NSPredicate {
+        let date = Date.threeMonthsAgo
+        return NSPredicate(format: "deliverAt >= %@", date as NSDate)
+    }
 }

+ 21 - 0
Model/Helper/GlucoseStored+helper.swift

@@ -27,6 +27,27 @@ extension GlucoseStored {
 
         return glucose.allSatisfy { $0.glucose == firstValue }
     }
+
+    // Preview
+    @discardableResult static func makePreviewGlucose(count: Int, provider: CoreDataStack) -> [GlucoseStored] {
+        let context = provider.persistentContainer.viewContext
+        let baseGlucose = 120
+        let glucoseValues = (0 ..< count).map { index -> GlucoseStored in
+            let glucose = GlucoseStored(context: context)
+            glucose.id = UUID()
+            glucose.date = Date.now.addingTimeInterval(Double(index) * -300) // Every 5 minutes
+            glucose.glucose = Int16(baseGlucose + (index % 3) * 10) // Varying between 120-140
+            glucose.direction = BloodGlucose.Direction.flat.rawValue
+            glucose.isManual = false
+            glucose.isUploadedToNS = false
+            glucose.isUploadedToHealth = false
+            glucose.isUploadedToTidepool = false
+            return glucose
+        }
+
+        try? context.save()
+        return glucoseValues
+    }
 }
 
 extension NSPredicate {

+ 36 - 1
Model/Helper/PumpEvent+helper.swift

@@ -12,6 +12,32 @@ extension PumpEventStored {
         }
         return request
     }
+
+    // Preview
+    @discardableResult static func makePreviewEvents(count: Int, provider: CoreDataStack) -> [PumpEventStored] {
+        let context = provider.persistentContainer.viewContext
+        let events = (0 ..< count).map { index -> PumpEventStored in
+            let event = PumpEventStored(context: context)
+            event.id = UUID().uuidString
+            event.timestamp = Date.now.addingTimeInterval(Double(index) * -300) // Every 5 minutes
+            event.type = EventType.bolus.rawValue
+            event.isUploadedToNS = false
+            event.isUploadedToHealth = false
+            event.isUploadedToTidepool = false
+
+            // Add a bolus
+            let bolus = BolusStored(context: context)
+            bolus.amount = 2.5 as NSDecimalNumber
+            bolus.isExternal = false
+            bolus.isSMB = false
+            bolus.pumpEvent = event
+
+            return event
+        }
+
+        try? context.save()
+        return events
+    }
 }
 
 public extension PumpEventStored {
@@ -63,9 +89,18 @@ extension NSPredicate {
         return NSPredicate(format: "timestamp >= %@", date as NSDate)
     }
 
+    static var pumpHistoryForStats: NSPredicate {
+        let date = Date.threeMonthsAgo
+        return NSPredicate(format: "pumpEvent.timestamp >= %@", date as NSDate)
+    }
+
     static var recentPumpHistory: NSPredicate {
         let date = Date.twentyMinutesAgo
-        return NSPredicate(format: "timestamp >= %@", date as NSDate)
+        return NSPredicate(
+            format: "type == %@ AND timestamp <= %@",
+            PumpEventStored.EventType.tempBasal.rawValue,
+            date as NSDate
+        )
     }
 
     static var lastPumpBolus: NSPredicate {

+ 11 - 0
Model/Helper/TempTargetStored+Helper.swift

@@ -14,6 +14,17 @@ extension NSPredicate {
             true as NSNumber
         )
     }
+
+    static var tempTargetsForMainChart: NSPredicate {
+        let date = Date.oneDayAgo
+        return NSPredicate(
+            format: "(date >= %@ AND enabled == %@) OR (date >= %@ AND enabled == %@)",
+            date as NSDate,
+            true as NSNumber,
+            Date() as NSDate,
+            false as NSNumber
+        )
+    }
 }
 
 extension TempTargetStored {

+ 16 - 2
Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents

@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
-<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23231" systemVersion="24C101" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23605" systemVersion="24C101" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
     <entity name="BolusStored" representedClassName="BolusStored" syncable="YES">
         <attribute name="amount" optional="YES" attributeType="Decimal" defaultValueString="0"/>
         <attribute name="isExternal" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
@@ -51,6 +51,9 @@
         <fetchIndex name="byDate">
             <fetchIndexElement property="date" type="Binary" order="descending"/>
         </fetchIndex>
+        <fetchIndex name="byValue">
+            <fetchIndexElement property="forecastValues" type="Binary" order="ascending"/>
+        </fetchIndex>
     </entity>
     <entity name="ForecastValue" representedClassName="ForecastValue" syncable="YES">
         <attribute name="index" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
@@ -135,11 +138,13 @@
         <attribute name="threshold" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
         <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
         <attribute name="timestampEnacted" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
-        <attribute name="totalDailyDose" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
         <relationship name="forecasts" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Forecast" inverseName="orefDetermination" inverseEntity="Forecast"/>
         <fetchIndex name="byDate">
             <fetchIndexElement property="deliverAt" type="Binary" order="descending"/>
         </fetchIndex>
+        <fetchIndex name="byTimestamp">
+            <fetchIndexElement property="timestamp" type="Binary" order="ascending"/>
+        </fetchIndex>
     </entity>
     <entity name="OverrideRunStored" representedClassName="OverrideRunStored" syncable="YES">
         <attribute name="endDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
@@ -205,6 +210,15 @@
     <entity name="StatsData" representedClassName="StatsData" syncable="YES">
         <attribute name="lastrun" attributeType="Date" defaultDateTimeInterval="704497620" usesScalarValueType="NO"/>
     </entity>
+    <entity name="TDDStored" representedClassName="TDDStored" syncable="YES">
+        <attribute name="bolus" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
+        <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
+        <attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
+        <attribute name="scheduledBasal" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
+        <attribute name="tempBasal" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
+        <attribute name="total" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
+        <attribute name="weightedAverage" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
+    </entity>
     <entity name="TempBasalStored" representedClassName="TempBasalStored" syncable="YES">
         <attribute name="duration" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
         <attribute name="rate" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>

+ 1 - 1
OmniKit

@@ -1 +1 @@
-Subproject commit 39915b05fe46b5d73eca52e156dd7efd11193ee8
+Subproject commit 92948a7684ec382714becc53c643a1617597bb37

+ 1 - 1
RileyLinkKit

@@ -1 +1 @@
-Subproject commit a0e419da314d0ad42b41ccb04af73cd1fbf41257
+Subproject commit eddfd4f00bbf0d78035dc31e6f7715e72252c566

+ 4 - 0
TDDStored+CoreDataClass.swift

@@ -0,0 +1,4 @@
+import CoreData
+import Foundation
+
+@objc(TDDStored) public class TDDStored: NSManagedObject {}

+ 18 - 0
TDDStored+CoreDataProperties.swift

@@ -0,0 +1,18 @@
+import CoreData
+import Foundation
+
+public extension TDDStored {
+    @nonobjc class func fetchRequest() -> NSFetchRequest<TDDStored> {
+        NSFetchRequest<TDDStored>(entityName: "TDDStored")
+    }
+
+    @NSManaged var id: UUID?
+    @NSManaged var date: Date?
+    @NSManaged var total: NSDecimalNumber?
+    @NSManaged var bolus: NSDecimalNumber?
+    @NSManaged var tempBasal: NSDecimalNumber?
+    @NSManaged var scheduledBasal: NSDecimalNumber?
+    @NSManaged var weightedAverage: NSDecimalNumber?
+}
+
+extension TDDStored: Identifiable {}

+ 11 - 0
Trio Watch App Extension/Views/AcknowledgementPendingView.swift

@@ -43,6 +43,17 @@ struct AcknowledgementPendingView: View {
         .navigationBarBackButtonHidden(true)
         .toolbar(.hidden)
         .background(trioBackgroundColor)
+        .onChange(of: state.showCommsAnimation) { oldValue, newValue in
+            if newValue && !oldValue {
+                DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
+                    // If after 5 seconds there is still no acknowledgement banner, return to root
+                    if !state.showAcknowledgmentBanner {
+                        // Navigate back to the root
+                        navigationPath.removeLast(navigationPath.count)
+                    }
+                }
+            }
+        }
         .onChange(of: state.showAcknowledgmentBanner) { _, newValue in
             if !newValue {
                 // Navigate back to the root when acknowledgment banner disappears

+ 1 - 1
Trio Watch App Extension/Views/BolusConfirmationView.swift

@@ -37,7 +37,7 @@ struct BolusConfirmationView: View {
                 HStack {
                     Text("Bolus")
                     Spacer()
-                    Text(String(format: "%.2f U", adjustedBolusAmount))
+                    Text(String(format: "%.2f \(String(localized: "U", comment: "Insulin unit"))", adjustedBolusAmount))
                         .bold()
                         .foregroundStyle(Color.insulin)
                 }.padding(.horizontal)

+ 9 - 3
Trio Watch App Extension/Views/BolusInputView.swift

@@ -25,7 +25,10 @@ struct BolusInputView: View {
     var body: some View {
         VStack {
             if state.showBolusCalculationProgress {
-                ProgressView("Calculating Bolus...")
+                ProgressView(String(
+                    localized: "Calculating Bolus...",
+                    comment: "Progress view text on watch when calculating bolus"
+                ))
                 Spacer()
             } else {
                 if effectiveBolusLimit <= 0 {
@@ -63,7 +66,7 @@ struct BolusInputView: View {
                         let bolusIncrement = Double(truncating: state.bolusIncrement as NSNumber)
                         let adjustedBolusAmount = floor(bolusAmount / bolusIncrement) * bolusIncrement
 
-                        Text(String(format: "%.2f U", adjustedBolusAmount))
+                        Text(String(format: "%.2f \(String(localized: "U", comment: "Insulin unit"))", adjustedBolusAmount))
                             .fontWeight(.bold)
                             .font(.system(.title2, design: .rounded))
                             .foregroundColor(bolusAmount > 0.0 && bolusAmount >= effectiveBolusLimit ? .loopRed : .primary)
@@ -117,7 +120,10 @@ struct BolusInputView: View {
                     .tint(Color.insulin)
                     .disabled(!(bolusAmount > 0.0) || bolusAmount > effectiveBolusLimit)
 
-                    Text(String(format: "Recommended: %.1f U", NSDecimalNumber(decimal: state.recommendedBolus).doubleValue))
+                    Text(String(
+                        format: "\(String(localized: "Recommended:", comment: "Recommended bolus on Watch")) %.1f \(String(localized: "U", comment: "Insulin unit"))",
+                        NSDecimalNumber(decimal: state.recommendedBolus).doubleValue
+                    ))
                         .font(.footnote)
                         .foregroundStyle(.secondary)
                 }

+ 4 - 1
Trio Watch App Extension/Views/BolusProgressOverlay.swift

@@ -47,7 +47,10 @@ struct BolusProgressOverlay: View {
                     .tint(progressGradient)
 
                 Text(String(
-                    format: "%.2f U of %.2f U",
+                    format: String(
+                        localized: "%.2f U of %.2f U",
+                        comment: "Format for showing delivered and active bolus amounts, 'x U of y U' on watch"
+                    ),
                     state.deliveredAmount,
                     state.activeBolusAmount
                 ))

+ 3 - 2
Trio Watch App Extension/Views/CarbsInputView.swift

@@ -22,7 +22,8 @@ struct CarbsInputView: View {
     )
 
     var body: some View {
-        let buttonLabel = continueToBolus ? "Proceed" : "Log Carbs"
+        let buttonLabel = continueToBolus ? String(localized: "Proceed", comment: "Button Label to Proceed to Bolus on Watch") :
+            String(localized: "Log Carbs", comment: "Button Label to Log Carbs on Watch")
 
         // TODO: introduce meal setting fpu enablement to conditional handle FPU
         VStack {
@@ -45,7 +46,7 @@ struct CarbsInputView: View {
                 Spacer()
 
                 // Display the current carb amount
-                Text(String(format: "%.0f g", carbsAmount))
+                Text(String(format: "%.0f \(String(localized: "g", comment: "gram of carbs"))", carbsAmount))
                     .fontWeight(.bold)
                     .font(.system(.title2, design: .rounded))
                     .foregroundColor(carbsAmount > 0.0 && carbsAmount >= effectiveCarbsLimit ? .loopRed : .primary)

+ 8 - 3
Trio Watch App Extension/Views/GlucoseTrendView.swift

@@ -148,9 +148,14 @@ struct GlucoseTrendView: View {
 
             Spacer()
 
-            Text(isWatchStateDated ? "STALE DATA" : state.lastLoopTime ?? "--")
-                .font(.system(size: minutesAgoFontSize))
-                .fontWidth(isWatchStateDated ? .expanded : .standard)
+            Text(
+                isWatchStateDated ?
+                    String(localized: "STALE DATA", comment: "Information displayed when watch app data outdated or stale.") :
+                    state
+                    .lastLoopTime ?? "--"
+            )
+            .font(.system(size: minutesAgoFontSize))
+            .fontWidth(isWatchStateDated ? .expanded : .standard)
 
             Spacer()
 

+ 3 - 3
Trio Watch App Extension/Views/TreatmentMenuView.swift

@@ -103,9 +103,9 @@ enum TreatmentOption: String, CaseIterable, Identifiable {
 
     var displayName: String {
         switch self {
-        case .mealBolusCombo: return "Meal & Bolus"
-        case .meal: return "Meal"
-        case .bolus: return "Bolus"
+        case .mealBolusCombo: return String(localized: "Meal & Bolus", comment: "Watch App Treatment Option 'Meal & Bolus'")
+        case .meal: return String(localized: "Meal", comment: "Watch App Treatment Option 'Meal'")
+        case .bolus: return String(localized: "Bolus", comment: "Watch App Treatment Option 'Bolus'")
         }
     }
 }

+ 4 - 5
Trio Watch App Extension/WatchState.swift

@@ -110,15 +110,13 @@ import WatchConnectivity
             acknowledgmentMessage = "\(message)"
         } else {
             print("⌚️ Acknowledgment failed: \(message)")
+            DispatchQueue.main.async {
+                self.showCommsAnimation = false // Hide progress animation
+            }
             acknowledgementStatus = .failure
             acknowledgmentMessage = "\(message)"
         }
 
-        DispatchQueue.main.async {
-            self.showCommsAnimation = false // Hide progress animation
-            self.showSyncingAnimation = false // Just ensure this is 100% set to false
-        }
-
         if isFinal {
             showAcknowledgmentBanner = true
             DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
@@ -352,6 +350,7 @@ import WatchConnectivity
                 // reset input amounts
                 self.bolusAmount = 0
                 self.carbsAmount = 0
+
                 // reset auth progress
                 self.confirmationProgress = 0
             }

Разница между файлами не показана из-за своего большого размера
+ 281 - 174
Trio.xcodeproj/project.pbxproj


+ 54 - 0
Trio.xcodeproj/xcshareddata/xcschemes/Trio Tests.xcscheme

@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "1620"
+   version = "1.7">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES"
+      buildArchitectures = "Automatic">
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      shouldAutocreateTestPlan = "YES">
+      <Testables>
+         <TestableReference
+            skipped = "NO">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "38FCF3EC25E9028E0078B0D1"
+               BuildableName = "TrioTests.xctest"
+               BlueprintName = "TrioTests"
+               ReferencedContainer = "container:Trio.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
+      </Testables>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 1 - 1
Trio.xcodeproj/xcshareddata/xcschemes/Trio Watch App.xcscheme

@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <Scheme
-   LastUpgradeVersion = "1600"
+   LastUpgradeVersion = "1620"
    version = "1.7">
    <BuildAction
       parallelizeBuildables = "YES"

+ 1 - 1
Trio.xcodeproj/xcshareddata/xcschemes/Trio Watch Complication Extension.xcscheme

@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <Scheme
-   LastUpgradeVersion = "1600"
+   LastUpgradeVersion = "1620"
    version = "2.0">
    <BuildAction
       parallelizeBuildables = "YES"

+ 2 - 2
Trio.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme

@@ -1,9 +1,9 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <Scheme
-   LastUpgradeVersion = "1240"
+   LastUpgradeVersion = "1620"
    version = "1.3">
    <BuildAction
-      parallelizeBuildables = "NO"
+      parallelizeBuildables = "YES"
       buildImplicitDependencies = "YES">
       <BuildActionEntries>
          <BuildActionEntry

BIN
Trio/Resources/Assets.xcassets/app_icon_images/trioCircledNoBackground.imageset/ComplicationIcon.png


+ 12 - 0
Trio/Resources/Assets.xcassets/app_icon_images/trioCircledNoBackground.imageset/Contents.json

@@ -0,0 +1,12 @@
+{
+  "images" : [
+    {
+      "filename" : "ComplicationIcon.png",
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Trio/Resources/Assets.xcassets/app_icons/trioCircledNoBackground.imageset/ComplicationIcon.png


+ 12 - 0
Trio/Resources/Assets.xcassets/app_icons/trioCircledNoBackground.imageset/Contents.json

@@ -0,0 +1,12 @@
+{
+  "images" : [
+    {
+      "filename" : "ComplicationIcon.png",
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

+ 0 - 23
Trio/Resources/Base.lproj/InfoPlist.strings

@@ -1,23 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC is used to scan Libre sensors.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "For authorized acces to bolus";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Calendar is used to create a new glucose events.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";
-
-/* Privacy - Contacts Usage Description */
-"NSContactsUsageDescription" = "Allows Trio to access your contacts for live updates to your Apple Watch contact complication using the 'Contact Trick' feature.";

+ 8 - 4
Trio/Resources/Info.plist

@@ -7,7 +7,6 @@
 	<key>BGTaskSchedulerPermittedIdentifiers</key>
 	<array>
 		<string>$(PRODUCT_BUNDLE_IDENTIFIER).background-task.critical-event-log</string>
-		<string>com.trio.cleanup</string>
 	</array>
 	<key>CBBundleDisplayName</key>
 	<string>$(APP_DISPLAY_NAME)</string>
@@ -76,10 +75,10 @@
 	<string>To create events with BG reading values, so that they can be viewed on Apple Watch and CarPlay</string>
 	<key>NSCalendarsUsageDescription</key>
 	<string>Calendar is used to create a new glucose events.</string>
-	<key>NSFaceIDUsageDescription</key>
-	<string>For authorized acces to bolus</string>
 	<key>NSContactsUsageDescription</key>
 	<string>Contact is used to create a Apple Watch complication</string>
+	<key>NSFaceIDUsageDescription</key>
+	<string>For authorized acces to bolus</string>
 	<key>NSHealthShareUsageDescription</key>
 	<string>Health App is used to store blood glucose, carbs and insulin</string>
 	<key>NSHealthUpdateUsageDescription</key>
@@ -108,7 +107,12 @@
 	<key>UIFileSharingEnabled</key>
 	<true/>
 	<key>UILaunchScreen</key>
-	<dict/>
+	<dict>
+		<key>UIColorName</key>
+		<string>Background_DarkBlue</string>
+		<key>UIImageName</key>
+		<string>trioCircledNoBackground</string>
+	</dict>
 	<key>UIRequiredDeviceCapabilities</key>
 	<array>
 		<string>armv7</string>

Разница между файлами не показана из-за своего большого размера
+ 1059 - 0
Trio/Resources/InfoPlist.xcstrings


+ 0 - 20
Trio/Resources/ar.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC is used to scan Libre sensors.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "For authorized acces to bolus";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Calendar is used to create a new glucose events.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Health App is used to store blood glucose, carbs and insulin";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Health App is used to store blood glucose, carbs and insulin";

+ 0 - 20
Trio/Resources/ca.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC is used to scan Libre sensors.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "For authorized acces to bolus";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Calendar is used to create a new glucose events.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";

+ 0 - 20
Trio/Resources/da.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC bruges til at scanne Libre sensorer.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth bliver brugt til at kommunikere med din insulin pumpe og dine glukose monitor enheder";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth bliver brugt til at kommunikere med din insulin pumpe og dine glukose monitor enheder";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "For autoriseret adgang til bolus";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Kalender bruges til at oprette en ny glucose begivenheder.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Health App bruges til at opbevare blodglukose, insulin og kulhydrater";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Health App bruges til at opbevare blodglukose, insulin og kulhydrater";

+ 0 - 20
Trio/Resources/de.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC wird zum Scannen von Libre Sensoren benutzt.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth wird verwendet, um mit Insulinpumpen und CGMs zu kommunizieren.";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth wird verwendet, um mit Insulinpumpen und CGMs zu kommunizieren.";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "Autorisierung für Bolusabgabe";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "BZ-Werte werden im Kalender als temporärer Termin erstellt.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Die Apple Health App wird zum Speichern von Blutzuckerwerten, Insulin und Kohlenhydraten verwendet";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Die Apple Health App wird zum Speichern von Blutzuckerwerten, Insulin und Kohlenhydraten verwendet";

+ 0 - 20
Trio/Resources/es.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC is used to scan Libre sensors.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "For authorized acces to bolus";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Calendar is used to create a new glucose events.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";

+ 0 - 20
Trio/Resources/fi.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC is used to scan Libre sensors.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "For authorized acces to bolus";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Calendar is used to create a new glucose events.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";

+ 0 - 20
Trio/Resources/fr.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC est utilisé pour scanner les capteurs Libre.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth est utilisé pour communiquer avec la pompe à insuline et les dispositifs de surveillance continue du glucose";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth est utilisé pour communiquer avec la pompe à insuline et les dispositifs de surveillance continue du glucose";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "Pour les accès autorisés au bolus";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Le calendrier est utilisé pour créer un nouvel événement de glycémie.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "L'application Santé est utilisée pour stocker la glycémie, l'insuline et les glucides";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "L'application Santé est utilisée pour stocker la glycémie, l'insuline et les glucides";

+ 0 - 20
Trio/Resources/he.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC is used to scan Libre sensors.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "For authorized acces to bolus";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Calendar is used to create a new glucose events.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";

+ 0 - 20
Trio/Resources/hu.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC is used to scan Libre sensors.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "For authorized acces to bolus";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Calendar is used to create a new glucose events.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";

+ 0 - 20
Trio/Resources/it.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC è usato per scansionare i sensori di Libre.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Il bluetooth viene utilizzato per comunicare con il microinfusore e i dispositivi CGM";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Il Bluetooth viene utilizzato per comunicare con il microinfusore e i dispositivi CGM";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "Per accesso autorizzato al bolo";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Il calendario è usato per creare nuovi eventi di glicemia.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "L'app Salute è usata per memorizzare glicemie, insulina e carboidrati";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "L'app Salute è usata per memorizzare glicemie, insulina e carboidrati";

Разница между файлами не показана из-за своего большого размера
+ 1 - 1
Trio/Resources/javascript/bundle/determine-basal.js


+ 1 - 3
Trio/Resources/json/defaults/freeaps/freeaps_settings.json

@@ -14,21 +14,19 @@
   "displayCalendarIOBandCOB" : false,
   "glucoseBadge" : false,
   "glucoseNotificationsAlways" : false,
-  "useAlarmSound" : false,
   "addSourceInfoToGlucoseNotifications" : false,
   "lowGlucose" : 72,
   "highGlucose" : 270,
   "carbsRequiredThreshold" : 10,
   "showCarbsRequiredBadge" : true,
   "useFPUconversion" : true,
-  "totalInsulinDisplayType": "totalDailyDose",
   "individualAdjustmentFactor" : 0.5,
   "timeCap" : 8,
   "minuteInterval" : 30,
   "delay" : 60,
   "useAppleHealth" : false,
   "smoothGlucose" : false,
-  "hbA1cDisplayUnit" : "percent",
+  "eA1cDisplayUnit" : "percent",
   "high" : 180,
   "low" : 70,
   "hours" : 6,

+ 0 - 20
Trio/Resources/nb.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC brukes til å skanne Libre-sensorer.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth brukes til å kommunisere med insulinpumpe og blodsukkersensor";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth brukes til å kommunisere med insulinpumpe og blodsukkersensor-enheter";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "For autorisert tilgang til bolus";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Kalender brukes til å opprette nye blodsukker-oppføringer.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Helse-appen brukes til å lagre blodsukker, insulin og karbohydrater";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Helse-appen brukes til å lagre blodsukker, insulin og karbohydrater";

+ 0 - 20
Trio/Resources/nl.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC wordt gebruikt voor het scannen van Libre sensoren.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth wordt gebruikt om te communiceren met de insuline pomp en de continue glucose meter";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth wordt gebruikt om te communiceren met de insuline pomp en de continue glucose meter";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "Voor geautoriseerde toegang tot bolus";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Agenda wordt gebruikt om nieuwe glucose gebeurtenissen aan te maken.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Apple Gezondheid wordt gebruikt om bloedglucose, insuline en koolhydraten op te slaan";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Apple Gezondheid wordt gebruikt om bloedglucose, insuline en koolhydraten op te slaan";

+ 0 - 20
Trio/Resources/pl.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC is used to scan Libre sensors.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "For authorized acces to bolus";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Calendar is used to create a new glucose events.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";

+ 0 - 20
Trio/Resources/pt-BR.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC is used to scan Libre sensors.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "For authorized acces to bolus";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Calendar is used to create a new glucose events.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";

+ 0 - 20
Trio/Resources/pt-PT.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC is used to scan Libre sensors.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "For authorized acces to bolus";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Calendar is used to create a new glucose events.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";

+ 0 - 20
Trio/Resources/ru.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC используется для сканирования сенсоров Libre.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth используется для связи с инсулиновой помпой и устройствами непрерывного мониторинга глюкозы";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth используется для связи с инсулиновой помпой и устройствами непрерывного мониторинга глюкозы";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "Для авторизованного болюса";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Календарь используется для создания новых событий о глюкозе.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Приложение здоровья используется для хранения глюкозы, инсулина и углеводов в крови";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Приложение здоровья используется для хранения глюкозы, инсулина и углеводов в крови";

+ 0 - 20
Trio/Resources/sk.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC sa používa na skenovanie snímačov Libre.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth sa používa pre komunikáciu s inzulínovou pumpou a zariadeniami na kontinuálne monitorovanie krvného cukru";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth sa používa pre komunikáciu s inzulínovou pumpou a zariadeniami na kontinuálne monitorovanie krvného cukru";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "Pre autorizovaný prístup k bolusu";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Kalendár slúži na vytvorenie novej udalosti s glukózou.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Aplikácia Zdravie sa používa na meranie glukózy v krvi, inzulínu a ukladanie sacharidov";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Aplikácia Zdravie sa používa na meranie glukózy v krvi, inzulínu a ukladanie sacharidov";

+ 0 - 20
Trio/Resources/sv.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC används för att skanna Libre-sensorn vid uppstart.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth används för att kommunicera med pump och kontinuerlig glukosmätare";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth används för att kommunicera med insulinpumpen och kontinuerliga glukosmätare";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "För auktoriserad åtkomst till bolus";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Kalendern används för att skapa kalenderhändelser för glukosvärden.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Appen Hälsa används för att lagra blodsockervärden, insulin samt kolhydrater";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Appen Hälsa används för att lagra blodsockervärden, insulin och kolhydrater.";

+ 0 - 20
Trio/Resources/tr.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC Libre sensörlerini taramak için kullanılır.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth, insülin pompası ve sürekli glikoz izleme cihazları ile iletişim kurmak için kullanılır";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth, insülin pompası ve sürekli glikoz izleme cihazları ile iletişim kurmak için kullanılır";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "Bolus'a yetkili erişim için";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Takvim, yeni bir glikoz olayı oluşturmak için kullanılır.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";

+ 0 - 20
Trio/Resources/uk.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC використовується для сканування сенсорів Libre.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth використовується для обміну з інсуліновими помпами та безперервним моніторингом глюкози";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth використовується для обміну з інсуліновими помпами та безперервним моніторингом глюкози";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "Для авторизованого болюсу";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Для створення нових подій глюкози використовується календар.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Додаток Health використовується для зберігання глюкози в крові, інсуліну та вуглеводів";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Додаток Health використовується для зберігання глюкози в крові, інсуліну та вуглеводів";

+ 0 - 20
Trio/Resources/vi.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC được sử dụng để quét các cảm biến Libre.";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "Bluetooth được sử dụng để liên lạc với máy bơm insulin và các thiết bị theo dõi đường huyết liên tục/CGM.";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "Bluetooth được sử dụng để liên lạc với máy bơm insulin và các thiết bị theo dõi đường huyết liên tục/CGM.";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "Được cấp quyền truy cập vào bolus";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "Lịch \nCalendar được sử dụng để tạo ra một sự kiện glucose mới.";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Ứng dụng sức khỏe \nHealth được sử dụng để lưu trữ đường huyết, insulin và carbohydrate";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Ứng dụng sức khỏe \nHealth được sử dụng để lưu trữ đường huyết, insulin và carbohydrate";

+ 0 - 20
Trio/Resources/zh-Hans.lproj/InfoPlist.strings

@@ -1,20 +0,0 @@
-/* Privacy - NFC Scan Usage Description */
-"NFCReaderUsageDescription" = "NFC 用于扫描 Libre 传感器。";
-
-/* Privacy - Bluetooth Always Usage Description */
-"NSBluetoothAlwaysUsageDescription" = "蓝牙用于与胰岛素泵和连续血糖监测设备进行通信";
-
-/* Privacy - Bluetooth Peripheral Usage Description */
-"NSBluetoothPeripheralUsageDescription" = "蓝牙用于与胰岛素泵和连续血糖监测设备进行通信";
-
-/* Privacy - Face ID Usage Description */
-"NSFaceIDUsageDescription" = "用于输注胰岛素授权";
-
-/* Privacy - Calendars Usage Description */
-"NSCalendarsUsageDescription" = "日历用于创建一个新的葡萄糖事件。";
-
-/* Privacy - Health Update Usage Description */
-"NSHealthUpdateUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";
-
-/* Privacy - Health Share Usage Description */
-"NSHealthShareUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";

+ 361 - 364
Trio/Sources/APS/APSManager.swift

@@ -20,8 +20,8 @@ protocol APSManager {
     var pumpExpiresAtDate: CurrentValueSubject<Date?, Never> { get }
     var isManualTempBasal: Bool { get }
     func enactTempBasal(rate: Double, duration: TimeInterval) async
-    func determineBasal() async -> Bool
-    func determineBasalSync() async
+    func determineBasal() async throws
+    func determineBasalSync() async throws
     func simulateDetermineBasal(simulatedCarbsAmount: Decimal, simulatedBolusAmount: Decimal) async -> Determination?
     func roundBolus(amount: Decimal) -> Decimal
     var lastError: CurrentValueSubject<Error?, Never> { get }
@@ -65,6 +65,7 @@ final class BaseAPSManager: APSManager, Injectable {
     @Injected() private var deviceDataManager: DeviceDataManager!
     @Injected() private var nightscout: NightscoutManager!
     @Injected() private var settingsManager: SettingsManager!
+    @Injected() private var tddStorage: TDDStorage!
     @Injected() private var broadcaster: Broadcaster!
     @Persisted(key: "lastLoopStartDate") private var lastLoopStartDate: Date = .distantPast
     @Persisted(key: "lastLoopDate") var lastLoopDate: Date = .distantPast {
@@ -80,7 +81,7 @@ final class BaseAPSManager: APSManager, Injectable {
 
     private var lifetime = Lifetime()
 
-    private var backGroundTaskID: UIBackgroundTaskIdentifier?
+    private var backgroundTaskID: UIBackgroundTaskIdentifier?
 
     var pumpManager: PumpManagerUI? {
         get { deviceDataManager.pumpManager }
@@ -130,7 +131,14 @@ final class BaseAPSManager: APSManager, Injectable {
             let wasParsed = storage.parseOnFileSettingsToMgdL()
             if wasParsed {
                 Task {
-                    await openAPS.createProfiles(useSwiftOref: settings.useSwiftOref)
+                    do {
+                        try await openAPS.createProfiles(useSwiftOref: settings.useSwiftOref)
+                    } catch {
+                        debug(
+                            .apsManager,
+                            "\(DebuggingIdentifiers.failed) Error creating profiles: \(error.localizedDescription)"
+                        )
+                    }
                 }
             }
         }
@@ -184,114 +192,142 @@ final class BaseAPSManager: APSManager, Injectable {
 
     // Loop entry point
     private func loop() {
-        Task {
-            // check the last start of looping is more the loopInterval but the previous loop was completed
-            if lastLoopDate > lastLoopStartDate {
-                guard lastLoopStartDate.addingTimeInterval(Config.loopInterval) < Date() else {
-                    debug(.apsManager, "too close to do a loop : \(lastLoopStartDate)")
-                    return
+        Task { [weak self] in
+            guard let self else { return }
+
+            // Check if we can start a new loop
+            guard await self.canStartNewLoop() else { return }
+
+            // Setup loop and background task
+            var (loopStatRecord, backgroundTask) = await self.setupLoop()
+
+            do {
+                // Execute loop logic
+                try await self.executeLoop(loopStatRecord: &loopStatRecord)
+
+                // Upload data to Nightscout if available
+                if let nightscoutManager = self.nightscout {
+                    await nightscoutManager.uploadCarbs()
+                    await nightscoutManager.uploadPumpHistory()
+                    await nightscoutManager.uploadOverrides()
+                    await nightscoutManager.uploadTempTargets()
                 }
+            } catch {
+                var updatedStats = loopStatRecord
+                updatedStats.end = Date()
+                updatedStats.duration = roundDouble((updatedStats.end! - updatedStats.start).timeInterval / 60, 2)
+                updatedStats.loopStatus = error.localizedDescription
+                await loopCompleted(error: error, loopStatRecord: updatedStats)
+                debug(.apsManager, "\(DebuggingIdentifiers.failed) Failed to complete Loop: \(error.localizedDescription)")
             }
 
-            guard !isLooping.value else {
-                warning(.apsManager, "Loop already in progress. Skip recommendation.")
-                return
+            // Cleanup background task
+            if let backgroundTask = backgroundTask {
+                await UIApplication.shared.endBackgroundTask(backgroundTask)
+                self.backgroundTaskID = .invalid
             }
+        }
+    }
 
-            // start background time extension
-            backGroundTaskID = await UIApplication.shared.beginBackgroundTask(withName: "Loop starting") {
-                guard let backgroundTask = self.backGroundTaskID else { return }
-                Task {
-                    UIApplication.shared.endBackgroundTask(backgroundTask)
-                }
-                self.backGroundTaskID = .invalid
+    private func canStartNewLoop() async -> Bool {
+        // Check if too soon for next loop
+        if lastLoopDate > lastLoopStartDate {
+            guard lastLoopStartDate.addingTimeInterval(Config.loopInterval) < Date() else {
+                debug(.apsManager, "Not enough time have passed since last loop at : \(lastLoopStartDate)")
+                return false
             }
+        }
 
-            lastLoopStartDate = Date()
+        // Check if loop already in progress
+        guard !isLooping.value else {
+            warning(.apsManager, "Loop already in progress. Skip recommendation.")
+            return false
+        }
 
-            var previousLoop = [LoopStatRecord]()
-            var interval: Double?
+        return true
+    }
 
-            do {
-                try await privateContext.perform {
-                    let requestStats = LoopStatRecord.fetchRequest() as NSFetchRequest<LoopStatRecord>
-                    let sortStats = NSSortDescriptor(key: "end", ascending: false)
-                    requestStats.sortDescriptors = [sortStats]
-                    requestStats.fetchLimit = 1
-                    previousLoop = try self.privateContext.fetch(requestStats)
-
-                    if (previousLoop.first?.end ?? .distantFuture) < self.lastLoopStartDate {
-                        interval = self.roundDouble(
-                            (self.lastLoopStartDate - (previousLoop.first?.end ?? Date())).timeInterval / 60,
-                            1
-                        )
-                    }
-                }
-            } catch let error as NSError {
-                debugPrint(
-                    "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch the last loop with error: \(error.userInfo)"
-                )
+    private func setupLoop() async -> (LoopStats, UIBackgroundTaskIdentifier?) {
+        // Start background task
+        let backgroundTask = await UIApplication.shared.beginBackgroundTask(withName: "Loop starting") { [weak self] in
+            guard let self, let backgroundTask = self.backgroundTaskID else { return }
+            Task {
+                UIApplication.shared.endBackgroundTask(backgroundTask)
             }
+            self.backgroundTaskID = .invalid
+        }
+        backgroundTaskID = backgroundTask
 
-            var loopStatRecord = LoopStats(
-                start: lastLoopStartDate,
-                loopStatus: "Starting",
-                interval: interval
-            )
+        // Set loop start time
+        lastLoopStartDate = Date()
 
-            isLooping.send(true)
+        // Calculate interval from previous loop
+        let interval = await calculateLoopInterval()
 
-            do {
-                if await !determineBasal() {
-                    throw APSError.apsError(message: "Determine basal failed")
-                }
+        // Create initial loop stats record
+        let loopStatRecord = LoopStats(
+            start: lastLoopStartDate,
+            loopStatus: "Starting",
+            interval: interval
+        )
 
-                // Open loop completed
-                guard settings.closedLoop else {
-                    loopStatRecord.end = Date()
-                    loopStatRecord.duration = roundDouble((loopStatRecord.end! - loopStatRecord.start).timeInterval / 60, 2)
-                    loopStatRecord.loopStatus = "Success"
-                    await loopCompleted(loopStatRecord: loopStatRecord)
-                    return
-                }
+        isLooping.send(true)
 
-                // Closed loop - enact Determination
-                try await enactDetermination()
-                loopStatRecord.end = Date()
-                loopStatRecord.duration = roundDouble((loopStatRecord.end! - loopStatRecord.start).timeInterval / 60, 2)
-                loopStatRecord.loopStatus = "Success"
-                await loopCompleted(loopStatRecord: loopStatRecord)
-            } catch {
-                loopStatRecord.end = Date()
-                loopStatRecord.duration = roundDouble((loopStatRecord.end! - loopStatRecord.start).timeInterval / 60, 2)
-                loopStatRecord.loopStatus = error.localizedDescription
-                await loopCompleted(error: error, loopStatRecord: loopStatRecord)
-            }
+        return (loopStatRecord, backgroundTask)
+    }
 
-            if let nightscoutManager = nightscout {
-                await nightscoutManager.uploadCarbs()
-                await nightscoutManager.uploadPumpHistory()
-                await nightscoutManager.uploadOverrides()
-                await nightscoutManager.uploadTempTargets()
-            }
+    private func executeLoop(loopStatRecord: inout LoopStats) async throws {
+        try await determineBasal()
 
-            // End background task after all the operations are completed
-            if let backgroundTask = self.backGroundTaskID {
-                await UIApplication.shared.endBackgroundTask(backgroundTask)
-                self.backGroundTaskID = .invalid
+        // Handle open loop
+        guard settings.closedLoop else {
+            loopStatRecord.end = Date()
+            loopStatRecord.duration = roundDouble((loopStatRecord.end! - loopStatRecord.start).timeInterval / 60, 2)
+            loopStatRecord.loopStatus = "Success"
+            await loopCompleted(loopStatRecord: loopStatRecord)
+            return
+        }
+
+        // Handle closed loop
+        try await enactDetermination()
+        loopStatRecord.end = Date()
+        loopStatRecord.duration = roundDouble((loopStatRecord.end! - loopStatRecord.start).timeInterval / 60, 2)
+        loopStatRecord.loopStatus = "Success"
+        await loopCompleted(loopStatRecord: loopStatRecord)
+    }
+
+    private func calculateLoopInterval() async -> Double? {
+        do {
+            return try await privateContext.perform {
+                let requestStats = LoopStatRecord.fetchRequest() as NSFetchRequest<LoopStatRecord>
+                let sortStats = NSSortDescriptor(key: "end", ascending: false)
+                requestStats.sortDescriptors = [sortStats]
+                requestStats.fetchLimit = 1
+                let previousLoop = try self.privateContext.fetch(requestStats)
+
+                if (previousLoop.first?.end ?? .distantFuture) < self.lastLoopStartDate {
+                    return self.roundDouble(
+                        (self.lastLoopStartDate - (previousLoop.first?.end ?? Date())).timeInterval / 60,
+                        1
+                    )
+                }
+                return nil
             }
+        } catch {
+            debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch the last loop with error: \(error)")
+            return nil
         }
     }
 
-//     Loop exit point
+    // Loop exit point
     private func loopCompleted(error: Error? = nil, loopStatRecord: LoopStats) async {
         isLooping.send(false)
 
         if let error = error {
             warning(.apsManager, "Loop failed with error: \(error.localizedDescription)")
-            if let backgroundTask = backGroundTaskID {
+            if let backgroundTask = backgroundTaskID {
                 await UIApplication.shared.endBackgroundTask(backgroundTask)
-                backGroundTaskID = .invalid
+                backgroundTaskID = .invalid
             }
             processError(error)
         } else {
@@ -307,9 +343,9 @@ final class BaseAPSManager: APSManager, Injectable {
         }
 
         // End of the BG tasks
-        if let backgroundTask = backGroundTaskID {
+        if let backgroundTask = backgroundTaskID {
             await UIApplication.shared.endBackgroundTask(backgroundTask)
-            backGroundTaskID = .invalid
+            backgroundTaskID = .invalid
         }
     }
 
@@ -346,14 +382,39 @@ final class BaseAPSManager: APSManager, Injectable {
         return false
     }
 
-    func determineBasal() async -> Bool {
+    /// Calculates and stores the Total Daily Dose (TDD)
+    private func calculateAndStoreTDD() async throws {
+        guard let pumpManager else { return }
+
+        async let pumpHistory = pumpHistoryStorage.getPumpHistory()
+        async let basalProfile = storage
+            .retrieveAsync(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self) ??
+            [BasalProfileEntry](from: OpenAPS.defaults(for: OpenAPS.Settings.basalProfile)) ??
+            [] // OpenAPS.defaults ensures we at least get default rate of 1u/hr for 24 hrs
+
+        // Calculate TDD
+        let tddResult = try await tddStorage.calculateTDD(
+            pumpManager: pumpManager,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile
+        )
+
+        // Store TDD in Core Data
+        await tddStorage.storeTDD(tddResult)
+    }
+
+    func determineBasal() async throws {
         debug(.apsManager, "Start determine basal")
 
+        try await calculateAndStoreTDD()
+
         // Fetch glucose asynchronously
-        let glucose = await fetchGlucose(predicate: NSPredicate.predicateForOneHourAgo, fetchLimit: 6)
+        let glucose = try await fetchGlucose(predicate: NSPredicate.predicateForOneHourAgo, fetchLimit: 6)
 
         // Perform the context-related checks and actions
-        let isValidGlucoseData = await privateContext.perform {
+        let isValidGlucoseData = await privateContext.perform { [weak self] in
+            guard let self else { return false }
+
             guard glucose.count > 2 else {
                 debug(.apsManager, "Not enough glucose data")
                 self.processError(APSError.glucoseError(message: "Not enough glucose data"))
@@ -379,7 +440,7 @@ final class BaseAPSManager: APSManager, Injectable {
         guard isValidGlucoseData else {
             debug(.apsManager, "Glucose validation failed")
             processError(APSError.glucoseError(message: "Glucose validation failed"))
-            return false
+            return
         }
 
         do {
@@ -390,32 +451,30 @@ final class BaseAPSManager: APSManager, Injectable {
             async let autosenseResult = autosense()
 
             _ = try await autosenseResult
-            await openAPS.createProfiles(useSwiftOref: settings.useSwiftOref)
+            try await openAPS.createProfiles(useSwiftOref: settings.useSwiftOref)
             let determination = try await openAPS.determineBasal(currentTemp: await currentTemp, clock: now)
 
             if let determination = determination {
-                DispatchQueue.main.async {
+                // Capture weak self in closure
+                await MainActor.run { [weak self] in
+                    guard let self else { return }
                     self.broadcaster.notify(DeterminationObserver.self, on: .main) {
                         $0.determinationDidUpdate(determination)
                     }
                 }
-                return true
-            } else {
-                return false
             }
         } catch {
-            debug(.apsManager, "Error determining basal: \(error)")
-            return false
+            throw APSError.apsError(message: "Error determining basal: \(error.localizedDescription)")
         }
     }
 
-    func determineBasalSync() async {
-        _ = await determineBasal()
+    func determineBasalSync() async throws {
+        _ = try await determineBasal()
     }
 
     func simulateDetermineBasal(simulatedCarbsAmount: Decimal, simulatedBolusAmount: Decimal) async -> Determination? {
         do {
-            let temp = await fetchCurrentTempBasal(date: Date.now)
+            let temp = try await fetchCurrentTempBasal(date: Date.now)
             return try await openAPS.determineBasal(
                 currentTemp: temp,
                 clock: Date(),
@@ -447,12 +506,14 @@ final class BaseAPSManager: APSManager, Injectable {
 
         if let error = verifyStatus() {
             processError(error)
-            processQueue.async {
-                self.broadcaster.notify(BolusFailureObserver.self, on: self.processQueue) {
+            // Capture broadcaster and queue before async context
+            let broadcaster = self.broadcaster
+            Task { @MainActor in
+                broadcaster?.notify(BolusFailureObserver.self, on: .main) {
                     $0.bolusDidFail()
                 }
             }
-            callback?(false, "Error! Failed to enact bolus.")
+            callback?(false, String(localized: "Error! Failed to enact bolus.", comment: "Error message for enacting a bolus"))
             return
         }
 
@@ -466,21 +527,26 @@ final class BaseAPSManager: APSManager, Injectable {
             try await pump.enactBolus(units: roundedAmount, automatic: isSMB)
             debug(.apsManager, "Bolus succeeded")
             if !isSMB {
-                await determineBasalSync()
+                try await determineBasalSync()
             }
             bolusProgress.send(0)
-            callback?(true, "Bolus enacted successfully.")
+            callback?(true, String(localized: "Bolus enacted successfully.", comment: "Success message for enacting a bolus"))
         } catch {
             warning(.apsManager, "Bolus failed with error: \(error.localizedDescription)")
             processError(APSError.pumpError(error))
             if !isSMB {
-                processQueue.async {
-                    self.broadcaster.notify(BolusFailureObserver.self, on: self.processQueue) {
+                // Use MainActor to handle broadcaster notification
+                let broadcaster = self.broadcaster
+                Task { @MainActor in
+                    broadcaster?.notify(BolusFailureObserver.self, on: .main) {
                         $0.bolusDidFail()
                     }
                 }
             }
-            callback?(false, "Error! Failed to enact bolus.")
+            callback?(
+                false,
+                String(localized: "Error! Failed to enact bolus.", comment: "Error message for failing to enact a bolus")
+            )
         }
     }
 
@@ -490,11 +556,14 @@ final class BaseAPSManager: APSManager, Injectable {
         do {
             _ = try await pump.cancelBolus()
             debug(.apsManager, "Bolus cancelled")
-            callback?(true, "Bolus cancelled successfully.")
+            callback?(true, String(localized: "Bolus cancelled successfully.", comment: "Success message for canceling a bolus"))
         } catch {
             debug(.apsManager, "Bolus cancellation failed with error: \(error.localizedDescription)")
             processError(APSError.pumpError(error))
-            callback?(false, "Error! Bolus cancellation failed.")
+            callback?(
+                false,
+                String(localized: "Error! Bolus cancellation failed.", comment: "Error message for canceling a bolus")
+            )
         }
         bolusReporter?.removeObserver(self)
         bolusReporter = nil
@@ -528,8 +597,8 @@ final class BaseAPSManager: APSManager, Injectable {
         }
     }
 
-    private func fetchCurrentTempBasal(date: Date) async -> TempBasal {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    private func fetchCurrentTempBasal(date: Date) async throws -> TempBasal {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             onContext: privateContext,
             predicate: NSPredicate.recentPumpHistory,
@@ -568,7 +637,7 @@ final class BaseAPSManager: APSManager, Injectable {
     }
 
     private func enactDetermination() async throws {
-        guard let determinationID = await determinationStorage
+        guard let determinationID = try await determinationStorage
             .fetchLastDeterminationObjectID(predicate: NSPredicate.predicateFor30MinAgoForDetermination).first
         else {
             throw APSError.apsError(message: "Determination not found")
@@ -623,35 +692,39 @@ final class BaseAPSManager: APSManager, Injectable {
     }
 
     private func reportEnacted(wasEnacted: Bool) async {
-        guard let determinationID = await determinationStorage
-            .fetchLastDeterminationObjectID(predicate: NSPredicate.predicateFor30MinAgoForDetermination).first
-        else {
-            return
-        }
-        await privateContext.perform {
-            if let determinationUpdated = self.privateContext.object(with: determinationID) as? OrefDetermination {
+        do {
+            guard let determinationID = try await determinationStorage
+                .fetchLastDeterminationObjectID(predicate: NSPredicate.predicateFor30MinAgoForDetermination).first
+            else {
+                debug(.apsManager, "No determination found to report enacted status")
+                return
+            }
+
+            try await privateContext.perform {
+                guard let determinationUpdated = try self.privateContext
+                    .existingObject(with: determinationID) as? OrefDetermination
+                else {
+                    debug(.apsManager, "Could not find determination object in context")
+                    return
+                }
+
                 determinationUpdated.timestamp = Date()
                 determinationUpdated.enacted = wasEnacted
                 determinationUpdated.isUploadedToNS = false
 
-                do {
-                    guard self.privateContext.hasChanges else { return }
-                    try self.privateContext.save()
-                    debugPrint("Update successful in reportEnacted() \(DebuggingIdentifiers.succeeded)")
-                } catch {
-                    debugPrint(
-                        "Failed  \(DebuggingIdentifiers.succeeded) to save context in reportEnacted(): \(error.localizedDescription)"
-                    )
-                }
-
+                guard self.privateContext.hasChanges else { return }
+                try self.privateContext.save()
                 debug(.apsManager, "Determination enacted. Enacted: \(wasEnacted)")
 
                 Task.detached(priority: .low) {
                     await self.statistics()
                 }
-            } else {
-                debugPrint("Failed to update OrefDetermination in reportEnacted()")
             }
+        } catch {
+            debug(
+                .apsManager,
+                "\(DebuggingIdentifiers.failed) Error reporting enacted status: \(error.localizedDescription)"
+            )
         }
     }
 
@@ -811,8 +884,8 @@ final class BaseAPSManager: APSManager, Injectable {
     }
 
     // fetch glucose for time interval
-    func fetchGlucose(predicate: NSPredicate, fetchLimit: Int? = nil, batchSize: Int? = nil) async -> [GlucoseStored] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func fetchGlucose(predicate: NSPredicate, fetchLimit: Int? = nil, batchSize: Int? = nil) async throws -> [GlucoseStored] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: privateContext,
             predicate: predicate,
@@ -822,9 +895,9 @@ final class BaseAPSManager: APSManager, Injectable {
             batchSize: batchSize
         )
 
-        return await privateContext.perform {
+        return try await privateContext.perform {
             guard let glucoseResults = results as? [GlucoseStored] else {
-                return []
+                throw CoreDataError.fetchError(function: #function, file: #file)
             }
 
             return glucoseResults
@@ -849,7 +922,7 @@ final class BaseAPSManager: APSManager, Injectable {
             async let carbTotal = carbsForStats()
             async let preferences = settingsManager.preferences
 
-            let loopStats = await loopStats(oneDayGlucose: await glucoseStats.oneDayGlucose.readings)
+            let loopStats = await loopStats(oneDayGlucose: Double(rawValue: (await glucoseStats?.oneDayGlucose.readings)!) ?? 0.0)
 
             // Only save and upload once per day
             guard (-1 * (await lastLoopForStats ?? .distantPast).timeIntervalSinceNow.hours) > 22 else { return }
@@ -872,31 +945,11 @@ final class BaseAPSManager: APSManager, Injectable {
             }
             let af = pref.adjustmentFactor
             let insulin_type = pref.curve
-//            let buildDate = Bundle.main.buildDate // TODO: fix this
+            let buildDate = BuildDetails.default.buildDate()
             let version = Bundle.main.releaseVersionNumber
             let build = Bundle.main.buildVersionNumber
 
-            // Read branch information from branch.txt instead of infoDictionary
-            var branch = "Unknown"
-            if let branchFileURL = Bundle.main.url(forResource: "branch", withExtension: "txt"),
-               let branchFileContent = try? String(contentsOf: branchFileURL)
-            {
-                let lines = branchFileContent.components(separatedBy: .newlines)
-                for line in lines {
-                    let components = line.components(separatedBy: "=")
-                    if components.count == 2 {
-                        let key = components[0].trimmingCharacters(in: .whitespaces)
-                        let value = components[1].trimmingCharacters(in: .whitespaces)
-
-                        if key == "BRANCH" {
-                            branch = value
-                            break
-                        }
-                    }
-                }
-            } else {
-                branch = "Unknown"
-            }
+            var branch = BuildDetails.default.branchAndSha
 
             let copyrightNotice_ = Bundle.main.infoDictionary?["NSHumanReadableCopyright"] as? String ?? ""
             let pump_ = pumpManager?.localizedTitle ?? ""
@@ -919,8 +972,9 @@ final class BaseAPSManager: APSManager, Injectable {
                 scheduled_basal: 0,
                 total_average: 0
             )
-            let processedGlucoseStats = await glucoseStats
-            let hbA1cDisplayUnit = processedGlucoseStats.hbA1cDisplayUnit
+            guard let processedGlucoseStats = await glucoseStats else { return }
+
+            let eA1cDisplayUnit = processedGlucoseStats.eA1cDisplayUnit
 
             let dailystat = await Statistics(
                 created_at: Date(),
@@ -930,7 +984,7 @@ final class BaseAPSManager: APSManager, Injectable {
                 Build_Number: build ?? "1",
                 Branch: branch,
                 CopyRightNotice: String(copyrightNotice_.prefix(32)),
-                Build_Date: Date(), // TODO: fix this
+                Build_Date: buildDate ?? Date(),
                 Algorithm: algo_,
                 AdjustmentFactor: af,
                 Pump: pump_,
@@ -938,12 +992,12 @@ final class BaseAPSManager: APSManager, Injectable {
                 insulinType: insulin_type.rawValue,
                 peakActivityTime: iPa,
                 Carbs_24h: await carbTotal,
-                GlucoseStorage_Days: Decimal(roundDouble(processedGlucoseStats.numberofDays, 1)),
+                GlucoseStorage_Days: Decimal(roundDouble(Double(rawValue: processedGlucoseStats.numberofDays) ?? 0.0, 1)),
                 Statistics: Stats(
                     Distribution: processedGlucoseStats.TimeInRange,
                     Glucose: processedGlucoseStats.avg,
-                    HbA1c: processedGlucoseStats.hbs,
-                    Units: Units(Glucose: units.rawValue, HbA1c: hbA1cDisplayUnit.rawValue),
+                    EstimatedA1c: processedGlucoseStats.hbs,
+                    Units: Units(Glucose: units.rawValue, EstimatedA1c: eA1cDisplayUnit.rawValue),
                     LoopCycles: loopStats,
                     Insulin: insulin,
                     Variance: processedGlucoseStats.variance
@@ -1063,210 +1117,153 @@ final class BaseAPSManager: APSManager, Injectable {
         }
     }
 
-    private func tddForStats() async -> (currentTDD: Decimal, tddTotalAverage: Decimal) {
-        let requestTDD = OrefDetermination.fetchRequest() as NSFetchRequest<NSFetchRequestResult>
-        let sort = NSSortDescriptor(key: "timestamp", ascending: false)
-        let daysOf14Ago = Date().addingTimeInterval(-14.days.timeInterval)
-        requestTDD.predicate = NSPredicate(format: "timestamp > %@", daysOf14Ago as NSDate)
-        requestTDD.sortDescriptors = [sort]
-        requestTDD.propertiesToFetch = ["timestamp", "totalDailyDose"]
-        requestTDD.resultType = .dictionaryResultType
-
-        var currentTDD: Decimal = 0
-        var tddTotalAverage: Decimal = 0
-
-        let results = await privateContext.perform {
-            do {
-                let fetchedResults = try self.privateContext.fetch(requestTDD) as? [[String: Any]]
-                return fetchedResults ?? []
-            } catch {
-                debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to get TDD Data for Statistics Upload")
-                return []
-            }
-        }
-
-        if !results.isEmpty {
-            if let latestTDD = results.first?["totalDailyDose"] as? NSDecimalNumber {
-                currentTDD = latestTDD.decimalValue
-            }
-            let tddArray = results.compactMap { ($0["totalDailyDose"] as? NSDecimalNumber)?.decimalValue }
-            if !tddArray.isEmpty {
-                tddTotalAverage = tddArray.reduce(0, +) / Decimal(tddArray.count)
-            }
-        }
-
-        return (currentTDD, tddTotalAverage)
-    }
-
-    private func glucoseForStats() async
-        -> (
-            oneDayGlucose: (
-                ifcc: Double,
-                ngsp: Double,
-                average: Double,
-                median: Double,
-                sd: Double,
-                cv: Double,
-                readings: Double
-            ),
-            hbA1cDisplayUnit: HbA1cDisplayUnit,
-            numberofDays: Double,
-            TimeInRange: TIRs,
-            avg: Averages,
-            hbs: Durations,
-            variance: Variance
-        )
-    {
-        // Get the Glucose Values
-        let glucose24h = await fetchGlucose(predicate: NSPredicate.predicateForOneDayAgo, fetchLimit: 288, batchSize: 50)
-        let glucoseOneWeek = await fetchGlucose(
-            predicate: NSPredicate.predicateForOneWeek,
-            fetchLimit: 288 * 7,
-            batchSize: 250
-        )
-        let glucoseOneMonth = await fetchGlucose(
-            predicate: NSPredicate.predicateForOneMonth,
-            fetchLimit: 288 * 7 * 30,
-            batchSize: 500
-        )
-        let glucoseThreeMonths = await fetchGlucose(
-            predicate: NSPredicate.predicateForThreeMonths,
-            fetchLimit: 288 * 7 * 30 * 3,
-            batchSize: 1000
-        )
+    private func glucoseForStats() async -> (
+        oneDayGlucose: (ifcc: Double, ngsp: Double, average: Double, median: Double, sd: Double, cv: Double, readings: Double),
+        eA1cDisplayUnit: EstimatedA1cDisplayUnit,
+        numberofDays: Double,
+        TimeInRange: TIRs,
+        avg: Averages,
+        hbs: Durations,
+        variance: Variance
+    )? {
+        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,
+                fetchLimit: 288 * 7,
+                batchSize: 250
+            )
+            let glucoseOneMonth = try await fetchGlucose(
+                predicate: NSPredicate.predicateForOneMonth,
+                fetchLimit: 288 * 7 * 30,
+                batchSize: 500
+            )
+            let glucoseThreeMonths = try await fetchGlucose(
+                predicate: NSPredicate.predicateForThreeMonths,
+                fetchLimit: 288 * 7 * 30 * 3,
+                batchSize: 1000
+            )
 
-        var result: (
-            oneDayGlucose: (
-                ifcc: Double,
-                ngsp: Double,
-                average: Double,
-                median: Double,
-                sd: Double,
-                cv: Double,
-                readings: Double
-            ),
-            hbA1cDisplayUnit: HbA1cDisplayUnit,
-            numberofDays: Double,
-            TimeInRange: TIRs,
-            avg: Averages,
-            hbs: Durations,
-            variance: Variance
-        )?
+            return await privateContext.perform {
+                let units = self.settingsManager.settings.units
+
+                // First date
+                let previous = glucoseThreeMonths.last?.date ?? Date()
+                // Last date (recent)
+                let current = glucoseThreeMonths.first?.date ?? Date()
+                // Total time in days
+                let numberOfDays = (current - previous).timeInterval / 8.64E4
+
+                // Get glucose computations for every case
+                let oneDayGlucose = self.glucoseStats(glucose24h)
+                let sevenDaysGlucose = self.glucoseStats(glucoseOneWeek)
+                let thirtyDaysGlucose = self.glucoseStats(glucoseOneMonth)
+                let totalDaysGlucose = self.glucoseStats(glucoseThreeMonths)
+
+                let median = Durations(
+                    day: self.roundDecimal(Decimal(oneDayGlucose.median), 1),
+                    week: self.roundDecimal(Decimal(sevenDaysGlucose.median), 1),
+                    month: self.roundDecimal(Decimal(thirtyDaysGlucose.median), 1),
+                    total: self.roundDecimal(Decimal(totalDaysGlucose.median), 1)
+                )
 
-        await privateContext.perform {
-            let units = self.settingsManager.settings.units
-
-            // First date
-            let previous = glucoseThreeMonths.last?.date ?? Date()
-            // Last date (recent)
-            let current = glucoseThreeMonths.first?.date ?? Date()
-            // Total time in days
-            let numberOfDays = (current - previous).timeInterval / 8.64E4
-
-            // Get glucose computations for every case
-            let oneDayGlucose = self.glucoseStats(glucose24h)
-            let sevenDaysGlucose = self.glucoseStats(glucoseOneWeek)
-            let thirtyDaysGlucose = self.glucoseStats(glucoseOneMonth)
-            let totalDaysGlucose = self.glucoseStats(glucoseThreeMonths)
-
-            let median = Durations(
-                day: self.roundDecimal(Decimal(oneDayGlucose.median), 1),
-                week: self.roundDecimal(Decimal(sevenDaysGlucose.median), 1),
-                month: self.roundDecimal(Decimal(thirtyDaysGlucose.median), 1),
-                total: self.roundDecimal(Decimal(totalDaysGlucose.median), 1)
-            )
+                let eA1cDisplayUnit = self.settingsManager.settings.eA1cDisplayUnit
+
+                let hbs = Durations(
+                    day: eA1cDisplayUnit == .mmolMol ?
+                        self.roundDecimal(Decimal(oneDayGlucose.ifcc), 1) :
+                        self.roundDecimal(Decimal(oneDayGlucose.ngsp), 1),
+                    week: eA1cDisplayUnit == .mmolMol ?
+                        self.roundDecimal(Decimal(sevenDaysGlucose.ifcc), 1) :
+                        self.roundDecimal(Decimal(sevenDaysGlucose.ngsp), 1),
+                    month: eA1cDisplayUnit == .mmolMol ?
+                        self.roundDecimal(Decimal(thirtyDaysGlucose.ifcc), 1) :
+                        self.roundDecimal(Decimal(thirtyDaysGlucose.ngsp), 1),
+                    total: eA1cDisplayUnit == .mmolMol ?
+                        self.roundDecimal(Decimal(totalDaysGlucose.ifcc), 1) :
+                        self.roundDecimal(Decimal(totalDaysGlucose.ngsp), 1)
+                )
 
-            let hbA1cDisplayUnit = self.settingsManager.settings.hbA1cDisplayUnit
-
-            let hbs = Durations(
-                day: hbA1cDisplayUnit == .mmolMol ?
-                    self.roundDecimal(Decimal(oneDayGlucose.ifcc), 1) :
-                    self.roundDecimal(Decimal(oneDayGlucose.ngsp), 1),
-                week: hbA1cDisplayUnit == .mmolMol ?
-                    self.roundDecimal(Decimal(sevenDaysGlucose.ifcc), 1) :
-                    self.roundDecimal(Decimal(sevenDaysGlucose.ngsp), 1),
-                month: hbA1cDisplayUnit == .mmolMol ?
-                    self.roundDecimal(Decimal(thirtyDaysGlucose.ifcc), 1) :
-                    self.roundDecimal(Decimal(thirtyDaysGlucose.ngsp), 1),
-                total: hbA1cDisplayUnit == .mmolMol ?
-                    self.roundDecimal(Decimal(totalDaysGlucose.ifcc), 1) :
-                    self.roundDecimal(Decimal(totalDaysGlucose.ngsp), 1)
-            )
+                var oneDay_: (TIR: Double, hypos: Double, hypers: Double, normal_: Double) = (0.0, 0.0, 0.0, 0.0)
+                var sevenDays_: (TIR: Double, hypos: Double, hypers: Double, normal_: Double) = (0.0, 0.0, 0.0, 0.0)
+                var thirtyDays_: (TIR: Double, hypos: Double, hypers: Double, normal_: Double) = (0.0, 0.0, 0.0, 0.0)
+                var totalDays_: (TIR: Double, hypos: Double, hypers: Double, normal_: Double) = (0.0, 0.0, 0.0, 0.0)
+                // Get TIR computations for every case
+                oneDay_ = self.tir(glucose24h)
+                sevenDays_ = self.tir(glucoseOneWeek)
+                thirtyDays_ = self.tir(glucoseOneMonth)
+                totalDays_ = self.tir(glucoseThreeMonths)
+
+                let tir = Durations(
+                    day: self.roundDecimal(Decimal(oneDay_.TIR), 1),
+                    week: self.roundDecimal(Decimal(sevenDays_.TIR), 1),
+                    month: self.roundDecimal(Decimal(thirtyDays_.TIR), 1),
+                    total: self.roundDecimal(Decimal(totalDays_.TIR), 1)
+                )
+                let hypo = Durations(
+                    day: Decimal(oneDay_.hypos),
+                    week: Decimal(sevenDays_.hypos),
+                    month: Decimal(thirtyDays_.hypos),
+                    total: Decimal(totalDays_.hypos)
+                )
+                let hyper = Durations(
+                    day: Decimal(oneDay_.hypers),
+                    week: Decimal(sevenDays_.hypers),
+                    month: Decimal(thirtyDays_.hypers),
+                    total: Decimal(totalDays_.hypers)
+                )
+                let normal = Durations(
+                    day: Decimal(oneDay_.normal_),
+                    week: Decimal(sevenDays_.normal_),
+                    month: Decimal(thirtyDays_.normal_),
+                    total: Decimal(totalDays_.normal_)
+                )
+                let range = Threshold(
+                    low: units == .mmolL ? self.roundDecimal(self.settingsManager.settings.low.asMmolL, 1) :
+                        self.roundDecimal(self.settingsManager.settings.low, 0),
+                    high: units == .mmolL ? self.roundDecimal(self.settingsManager.settings.high.asMmolL, 1) :
+                        self.roundDecimal(self.settingsManager.settings.high, 0)
+                )
+                let TimeInRange = TIRs(
+                    TIR: tir,
+                    Hypos: hypo,
+                    Hypers: hyper,
+                    Threshold: range,
+                    Euglycemic: normal
+                )
+                let avgs = Durations(
+                    day: self.roundDecimal(Decimal(oneDayGlucose.average), 1),
+                    week: self.roundDecimal(Decimal(sevenDaysGlucose.average), 1),
+                    month: self.roundDecimal(Decimal(thirtyDaysGlucose.average), 1),
+                    total: self.roundDecimal(Decimal(totalDaysGlucose.average), 1)
+                )
+                let avg = Averages(Average: avgs, Median: median)
+                // Standard Deviations
+                let standardDeviations = Durations(
+                    day: self.roundDecimal(Decimal(oneDayGlucose.sd), 1),
+                    week: self.roundDecimal(Decimal(sevenDaysGlucose.sd), 1),
+                    month: self.roundDecimal(Decimal(thirtyDaysGlucose.sd), 1),
+                    total: self.roundDecimal(Decimal(totalDaysGlucose.sd), 1)
+                )
+                // CV = standard deviation / sample mean x 100
+                let cvs = Durations(
+                    day: self.roundDecimal(Decimal(oneDayGlucose.cv), 1),
+                    week: self.roundDecimal(Decimal(sevenDaysGlucose.cv), 1),
+                    month: self.roundDecimal(Decimal(thirtyDaysGlucose.cv), 1),
+                    total: self.roundDecimal(Decimal(totalDaysGlucose.cv), 1)
+                )
+                let variance = Variance(SD: standardDeviations, CV: cvs)
 
-            var oneDay_: (TIR: Double, hypos: Double, hypers: Double, normal_: Double) = (0.0, 0.0, 0.0, 0.0)
-            var sevenDays_: (TIR: Double, hypos: Double, hypers: Double, normal_: Double) = (0.0, 0.0, 0.0, 0.0)
-            var thirtyDays_: (TIR: Double, hypos: Double, hypers: Double, normal_: Double) = (0.0, 0.0, 0.0, 0.0)
-            var totalDays_: (TIR: Double, hypos: Double, hypers: Double, normal_: Double) = (0.0, 0.0, 0.0, 0.0)
-            // Get TIR computations for every case
-            oneDay_ = self.tir(glucose24h)
-            sevenDays_ = self.tir(glucoseOneWeek)
-            thirtyDays_ = self.tir(glucoseOneMonth)
-            totalDays_ = self.tir(glucoseThreeMonths)
-
-            let tir = Durations(
-                day: self.roundDecimal(Decimal(oneDay_.TIR), 1),
-                week: self.roundDecimal(Decimal(sevenDays_.TIR), 1),
-                month: self.roundDecimal(Decimal(thirtyDays_.TIR), 1),
-                total: self.roundDecimal(Decimal(totalDays_.TIR), 1)
-            )
-            let hypo = Durations(
-                day: Decimal(oneDay_.hypos),
-                week: Decimal(sevenDays_.hypos),
-                month: Decimal(thirtyDays_.hypos),
-                total: Decimal(totalDays_.hypos)
-            )
-            let hyper = Durations(
-                day: Decimal(oneDay_.hypers),
-                week: Decimal(sevenDays_.hypers),
-                month: Decimal(thirtyDays_.hypers),
-                total: Decimal(totalDays_.hypers)
-            )
-            let normal = Durations(
-                day: Decimal(oneDay_.normal_),
-                week: Decimal(sevenDays_.normal_),
-                month: Decimal(thirtyDays_.normal_),
-                total: Decimal(totalDays_.normal_)
-            )
-            let range = Threshold(
-                low: units == .mmolL ? self.roundDecimal(self.settingsManager.settings.low.asMmolL, 1) :
-                    self.roundDecimal(self.settingsManager.settings.low, 0),
-                high: units == .mmolL ? self.roundDecimal(self.settingsManager.settings.high.asMmolL, 1) :
-                    self.roundDecimal(self.settingsManager.settings.high, 0)
-            )
-            let TimeInRange = TIRs(
-                TIR: tir,
-                Hypos: hypo,
-                Hypers: hyper,
-                Threshold: range,
-                Euglycemic: normal
-            )
-            let avgs = Durations(
-                day: self.roundDecimal(Decimal(oneDayGlucose.average), 1),
-                week: self.roundDecimal(Decimal(sevenDaysGlucose.average), 1),
-                month: self.roundDecimal(Decimal(thirtyDaysGlucose.average), 1),
-                total: self.roundDecimal(Decimal(totalDaysGlucose.average), 1)
-            )
-            let avg = Averages(Average: avgs, Median: median)
-            // Standard Deviations
-            let standardDeviations = Durations(
-                day: self.roundDecimal(Decimal(oneDayGlucose.sd), 1),
-                week: self.roundDecimal(Decimal(sevenDaysGlucose.sd), 1),
-                month: self.roundDecimal(Decimal(thirtyDaysGlucose.sd), 1),
-                total: self.roundDecimal(Decimal(totalDaysGlucose.sd), 1)
-            )
-            // CV = standard deviation / sample mean x 100
-            let cvs = Durations(
-                day: self.roundDecimal(Decimal(oneDayGlucose.cv), 1),
-                week: self.roundDecimal(Decimal(sevenDaysGlucose.cv), 1),
-                month: self.roundDecimal(Decimal(thirtyDaysGlucose.cv), 1),
-                total: self.roundDecimal(Decimal(totalDaysGlucose.cv), 1)
+                return (oneDayGlucose, eA1cDisplayUnit, numberOfDays, TimeInRange, avg, hbs, variance)
+            }
+        } catch {
+            debug(
+                .apsManager,
+                "\(DebuggingIdentifiers.failed) Error fetching glucose for stats: \(error.localizedDescription)"
             )
-            let variance = Variance(SD: standardDeviations, CV: cvs)
-
-            result = (oneDayGlucose, hbA1cDisplayUnit, numberOfDays, TimeInRange, avg, hbs, variance)
+            return nil
         }
-
-        return result!
     }
 
     private func loopStats(loopStatRecord: LoopStats) {

+ 1 - 1
Trio/Sources/APS/CGM/AppGroupSource.swift

@@ -115,7 +115,7 @@ struct AppGroupSource: GlucoseSource {
     }
 
     func sourceInfo() -> [String: Any]? {
-        [GlucoseSourceKey.description.rawValue: "Group ID: \(Bundle.main.appGroupSuiteName ?? "Not set"))"]
+        [GlucoseSourceKey.description.rawValue: "Group ID: \(Bundle.main.appGroupSuiteName ?? String(localized: "Not set"))"]
     }
 }
 

+ 8 - 7
Trio/Sources/APS/CGM/CGMType.swift

@@ -18,7 +18,7 @@ enum CGMType: String, JSON, CaseIterable, Identifiable {
         case .xdrip:
             return "xDrip4iOS"
         case .simulator:
-            return NSLocalizedString("Glucose Simulator", comment: "Glucose Simulator CGM type")
+            return String(localized: "Glucose Simulator", comment: "Glucose Simulator CGM type")
         case .enlite:
             return "Medtronic Enlite"
         case .plugin:
@@ -52,20 +52,21 @@ enum CGMType: String, JSON, CaseIterable, Identifiable {
     var subtitle: String {
         switch self {
         case .none:
-            return NSLocalizedString("None", comment: "No CGM selected")
+            return String(localized: "None", comment: "No CGM selected")
         case .nightscout:
-            return NSLocalizedString("Uses your Nightscout as CGM", comment: "Online or internal server")
+            return String(localized: "Uses your Nightscout as CGM", comment: "Online or internal server")
         case .xdrip:
-            return NSLocalizedString(
+            return String(
+                localized:
                 "Using shared app group with external CGM app xDrip4iOS",
                 comment: "Shared app group xDrip4iOS"
             )
         case .simulator:
-            return NSLocalizedString("Glucose Simulator for Demo Only", comment: "Simple simulator")
+            return String(localized: "Glucose Simulator for Demo Only", comment: "Simple simulator")
         case .enlite:
-            return NSLocalizedString("Minilink transmitter", comment: "Minilink transmitter")
+            return String(localized: "Minilink transmitter", comment: "Minilink transmitter")
         case .plugin:
-            return NSLocalizedString("Plugin CGM", comment: "Plugin CGM")
+            return String(localized: "Plugin CGM", comment: "Plugin CGM")
         }
     }
 }

+ 185 - 90
Trio/Sources/APS/CGM/GlucoseSimulatorSource.swift

@@ -10,15 +10,7 @@
 ///
 /// class GlucoseSimulatorSource - main class
 /// protocol BloodGlucoseGenerator
-///  - IntelligentGenerator: BloodGlucoseGenerator
-
-// TODO: Every itteration trend make two steps, but must only one
-
-// TODO: Trend's value sticks to max and min Glucose value (in Glucose Generator)
-
-// TODO: Add reaction to insulin
-
-// TODO: Add probability to set trend's target value. Middle values must have more probability, than max and min.
+///  - OscillatingGenerator: BloodGlucoseGenerator - Generates sinusoidal glucose values around a center point
 
 import Combine
 import Foundation
@@ -26,22 +18,29 @@ import LoopKitUI
 
 // MARK: - Glucose simulator
 
+/// A class that simulates glucose values for testing purposes.
+/// This class implements the GlucoseSource protocol and provides simulated glucose readings
+/// using different generator strategies.
 final class GlucoseSimulatorSource: GlucoseSource {
     var cgmManager: CGMManagerUI?
     var glucoseManager: FetchGlucoseManager?
 
     private enum Config {
-        // min time period to publish data
+        /// Minimum time period between data publications (in seconds)
         static let workInterval: TimeInterval = 300
-        // default BloodGlucose item at first run
-        // 288 = 1 day * 24 hours * 60 minites * 60 seconds / workInterval
+        /// Default number of blood glucose items to generate at first run
+        /// 288 = 1 day * 24 hours * 60 minutes * 60 seconds / workInterval
         static let defaultBGItems = 288
     }
 
+    /// The last glucose value that was generated
     @Persisted(key: "GlucoseSimulatorLastGlucose") private var lastGlucose = 100
 
+    /// The date of the last fetch operation
     @Persisted(key: "GlucoseSimulatorLastFetchDate") private var lastFetchDate: Date! = nil
 
+    /// Initializes the glucose simulator source
+    /// Sets up the initial fetch date if not already set
     init() {
         if lastFetchDate == nil {
             var lastDate = Date()
@@ -52,12 +51,13 @@ final class GlucoseSimulatorSource: GlucoseSource {
         }
     }
 
+    /// The glucose generator used to create simulated values
+    /// Uses OscillatingGenerator to create a sinusoidal pattern around 120 mg/dL
     private lazy var generator: BloodGlucoseGenerator = {
-        IntelligentGenerator(
-            currentGlucose: lastGlucose
-        )
+        OscillatingGenerator()
     }()
 
+    /// Determines if new glucose values can be generated based on the time elapsed since the last fetch
     private var canGenerateNewValues: Bool {
         guard let lastDate = lastFetchDate else { return true }
         if Calendar.current.dateComponents([.second], from: lastDate, to: Date()).second! >= Int(Config.workInterval) {
@@ -67,6 +67,9 @@ final class GlucoseSimulatorSource: GlucoseSource {
         }
     }
 
+    /// Fetches new glucose values if enough time has passed since the last fetch
+    /// - Parameter timer: Optional dispatch timer (not used in this implementation)
+    /// - Returns: A publisher that emits an array of BloodGlucose objects
     func fetch(_: DispatchTimer?) -> AnyPublisher<[BloodGlucose], Never> {
         guard canGenerateNewValues else {
             return Just([]).eraseToAnyPublisher()
@@ -86,6 +89,8 @@ final class GlucoseSimulatorSource: GlucoseSource {
         return Just(glucoses).eraseToAnyPublisher()
     }
 
+    /// Fetches new glucose values if needed
+    /// - Returns: A publisher that emits an array of BloodGlucose objects
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
         fetch(nil)
     }
@@ -93,105 +98,195 @@ final class GlucoseSimulatorSource: GlucoseSource {
 
 // MARK: - Glucose generator
 
+/// Protocol defining the interface for glucose generators
+/// Implementations of this protocol provide different strategies for generating glucose values
 protocol BloodGlucoseGenerator {
+    /// Generates blood glucose values between the specified dates at the given interval
+    /// - Parameters:
+    ///   - startDate: The start date for generating values
+    ///   - finishDate: The end date for generating values
+    ///   - interval: The time interval between generated values
+    /// - Returns: An array of BloodGlucose objects
     func getBloodGlucoses(startDate: Date, finishDate: Date, withInterval: TimeInterval) -> [BloodGlucose]
 }
 
-class IntelligentGenerator: BloodGlucoseGenerator {
-    private enum Config {
-        // max and min glucose of trend's target
-        static let maxGlucose = 320
-        static let minGlucose = 45
+/// A glucose generator that creates a sinusoidal pattern around a center value
+/// This generator simulates a realistic oscillating glucose pattern with configurable parameters
+class OscillatingGenerator: BloodGlucoseGenerator {
+    /// Default values for simulator parameters
+    enum Defaults {
+        static let centerValue: Double = 120.0
+        static let amplitude: Double = 45.0
+        static let period: Double = 10800.0 // 3 hours in seconds
+        static let noiseAmplitude: Double = 5.0
+        static let produceStaleValues: Bool = false
     }
 
-    // target glucose of trend
-    @Persisted(key: "GlucoseSimulatorTargetValue") private var trendTargetValue = 100
-    // how many steps left in current trend
-    @Persisted(key: "GlucoseSimulatorTargetSteps") private var trendStepsLeft = 1
-    // direction of last step
-    @Persisted(key: "GlucoseSimulatorDirection") private var trandsStepDirection = BloodGlucose.Direction.flat.rawValue
-    var currentGlucose: Int
-    let startup = Date()
-    init(currentGlucose: Int) {
-        self.currentGlucose = currentGlucose
+    /// UserDefaults keys for storing simulator parameters
+    private enum UserDefaultsKeys {
+        static let centerValue = "GlucoseSimulator_CenterValue"
+        static let amplitude = "GlucoseSimulator_Amplitude"
+        static let period = "GlucoseSimulator_Period"
+        static let noiseAmplitude = "GlucoseSimulator_NoiseAmplitude"
+        static let produceStaleValues = "GlucoseSimulator_ProduceStaleValues"
     }
 
-    func getBloodGlucoses(startDate: Date, finishDate: Date, withInterval interval: TimeInterval) -> [BloodGlucose] {
-        var result = [BloodGlucose]()
-
-        var _currentDate = startDate
-        while _currentDate <= finishDate {
-            result.append(getNextBloodGlucose(forDate: _currentDate))
-            _currentDate = _currentDate.addingTimeInterval(interval)
-        }
+    /// Amplitude of the oscillation (±45 mg/dL to create range from ~80 to ~170)
+    private var amplitude: Double {
+        get { UserDefaults.standard.double(forKey: UserDefaultsKeys.amplitude) != 0 ?
+            UserDefaults.standard.double(forKey: UserDefaultsKeys.amplitude) :
+            Defaults.amplitude }
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.amplitude) }
+    }
 
-        return result
+    /// Period of the oscillation in seconds (3 hours = 10800 seconds)
+    private var period: Double {
+        get { UserDefaults.standard.double(forKey: UserDefaultsKeys.period) != 0 ?
+            UserDefaults.standard.double(forKey: UserDefaultsKeys.period) :
+            Defaults.period }
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.period) }
     }
 
-    // get next glucose's value in current trend
-    private func getNextBloodGlucose(forDate date: Date) -> BloodGlucose {
-        let previousGlucose = currentGlucose
-        makeStepInTrend()
-        trandsStepDirection = getDirection(fromGlucose: previousGlucose, toGlucose: currentGlucose).rawValue
-        let glucose = BloodGlucose(
-            _id: UUID().uuidString,
-            sgv: currentGlucose,
-            direction: BloodGlucose.Direction(rawValue: trandsStepDirection),
-            date: Decimal(Int(date.timeIntervalSince1970) * 1000),
-            dateString: date,
-            unfiltered: Decimal(currentGlucose),
-            filtered: nil,
-            noise: nil,
-            glucose: currentGlucose,
-            type: nil,
-            activationDate: startup,
-            sessionStartDate: startup,
-            transmitterID: "SIMULATOR"
-        )
-        return glucose
+    /// Center value of the oscillation (target glucose level)
+    private var centerValue: Double {
+        get { UserDefaults.standard.double(forKey: UserDefaultsKeys.centerValue) != 0 ?
+            UserDefaults.standard.double(forKey: UserDefaultsKeys.centerValue) :
+            Defaults.centerValue }
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.centerValue) }
     }
 
-    private func setNewRandomTarget() {
-        guard trendTargetValue > 0 else {
-            trendTargetValue = Array(80 ... 110).randomElement()!
-            return
-        }
-        let difference = (Array(-50 ... -20) + Array(20 ... 50)).randomElement()!
-        let _value = trendTargetValue + difference
-        if _value <= Config.minGlucose {
-            trendTargetValue = Config.minGlucose
-        } else if _value >= Config.maxGlucose {
-            trendTargetValue = Config.maxGlucose
-        } else {
-            trendTargetValue = _value
-        }
+    /// Amplitude of random noise to add to the values (±5 mg/dL)
+    private var noiseAmplitude: Double {
+        get { UserDefaults.standard.double(forKey: UserDefaultsKeys.noiseAmplitude) != 0 ?
+            UserDefaults.standard.double(forKey: UserDefaultsKeys.noiseAmplitude) :
+            Defaults.noiseAmplitude }
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.noiseAmplitude) }
     }
 
-    private func setNewRandomSteps() {
-        trendStepsLeft = Array(3 ... 8).randomElement()!
+    /// Whether to produce stale (unchanging) glucose values
+    var produceStaleValues: Bool {
+        get { UserDefaults.standard.bool(forKey: UserDefaultsKeys.produceStaleValues) }
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.produceStaleValues) }
     }
 
-    private func getDirection(fromGlucose from: Int, toGlucose to: Int) -> BloodGlucose.Direction {
-        BloodGlucose.Direction(trend: Int(to - from))
+    /// Start date for the simulation
+    private let startup = Date()
+
+    /// Last generated glucose value for stale mode
+    private var lastGeneratedGlucose: Int?
+
+    /// Provides information string to describe the simulator as glucose source
+    func sourceInfo() -> [String: Any]? {
+        [GlucoseSourceKey.description.rawValue: "Glucose simulator"]
     }
 
-    private func generateNewTrend() {
-        setNewRandomTarget()
-        setNewRandomSteps()
+    /// Reset all parameters to default values
+    func resetToDefaults() {
+        centerValue = Defaults.centerValue
+        amplitude = Defaults.amplitude
+        period = Defaults.period
+        noiseAmplitude = Defaults.noiseAmplitude
+        produceStaleValues = Defaults.produceStaleValues
+        lastGeneratedGlucose = nil
     }
 
-    private func makeStepInTrend() {
-        guard trendStepsLeft > 0 else { return }
+    /// Generates blood glucose values between the specified dates at the given interval
+    /// - Parameters:
+    ///   - startDate: The start date for generating values
+    ///   - finishDate: The end date for generating values
+    ///   - interval: The time interval between generated values
+    /// - Returns: An array of BloodGlucose objects with sinusoidal pattern
+    func getBloodGlucoses(startDate: Date, finishDate: Date, withInterval interval: TimeInterval) -> [BloodGlucose] {
+        var result = [BloodGlucose]()
+        var currentDate = startDate
+
+        while currentDate <= finishDate {
+            let glucose: Int
+            let direction: BloodGlucose.Direction
+
+            if produceStaleValues, lastGeneratedGlucose != nil {
+                // In stale mode, use the last generated glucose value
+                glucose = lastGeneratedGlucose!
+                direction = .flat
+            } else {
+                // Generate a new glucose value
+                glucose = generate(date: currentDate)
+                direction = calculateDirection(at: currentDate)
+                lastGeneratedGlucose = glucose
+            }
 
-        currentGlucose +=
-            Int(Double((trendTargetValue - currentGlucose) / trendStepsLeft) * [0.3, 0.6, 1, 1.3, 1.6, 2.0].randomElement()!)
-        trendStepsLeft -= 1
-        if trendStepsLeft == 0 {
-            generateNewTrend()
+            // Create BloodGlucose with the correct constructor
+            let bloodGlucose = BloodGlucose(
+                _id: UUID().uuidString,
+                sgv: glucose,
+                direction: direction,
+                date: Decimal(Int(currentDate.timeIntervalSince1970) * 1000),
+                dateString: currentDate,
+                unfiltered: Decimal(glucose),
+                filtered: nil,
+                noise: nil,
+                glucose: glucose,
+                type: nil,
+                activationDate: startup,
+                sessionStartDate: startup,
+                transmitterID: "SIMULATOR"
+            )
+
+            result.append(bloodGlucose)
+            currentDate = currentDate.addingTimeInterval(interval)
         }
+
+        return result
     }
 
-    func sourceInfo() -> [String: Any]? {
-        [GlucoseSourceKey.description.rawValue: "Glucose simulator"]
+    /// Generates a glucose value for the specified date using a sinusoidal function
+    /// - Parameter date: The date for which to generate the glucose value
+    /// - Returns: An integer representing the glucose value in mg/dL
+    private func generate(date: Date) -> Int {
+        // Time in seconds since 1970
+        let timeSeconds = date.timeIntervalSince1970
+
+        // Calculate sine value
+        let sinValue = sin(2.0 * .pi * timeSeconds / period)
+
+        // Random noise
+        let noise = Double.random(in: -noiseAmplitude ... noiseAmplitude)
+
+        // Calculate glucose value: center + amplitude * sine + noise
+        let glucoseValue = centerValue + amplitude * sinValue + noise
+
+        // Return as integer
+        return Int(glucoseValue)
+    }
+
+    /// Calculates the direction (trend) of glucose change at the specified date
+    /// - Parameter date: The date for which to calculate the direction
+    /// - Returns: A BloodGlucose.Direction value indicating the trend
+    private func calculateDirection(at date: Date) -> BloodGlucose.Direction {
+        // Time in seconds since 1970
+        let timeSeconds = date.timeIntervalSince1970
+
+        // Calculate derivative of sine function (cosine)
+        let cosValue = cos(2.0 * .pi * timeSeconds / period)
+
+        // Slope of the curve at this point
+        let slope = -amplitude * 2.0 * .pi / period * cosValue
+
+        // Determine direction based on slope
+        if abs(slope) < 0.2 {
+            return .flat
+        } else if slope > 0 {
+            if slope > 1.0 {
+                return .singleUp
+            } else {
+                return .fortyFiveUp
+            }
+        } else {
+            if slope < -1.0 {
+                return .singleDown
+            } else {
+                return .fortyFiveDown
+            }
+        }
     }
 }

+ 64 - 31
Trio/Sources/APS/CGM/PluginSource.swift

@@ -105,53 +105,83 @@ extension PluginSource: CGMManagerDelegate {
     func recordRetractedAlert(_: LoopKit.Alert, at _: Date) {}
 
     func cgmManagerWantsDeletion(_ manager: CGMManager) {
-        dispatchPrecondition(condition: .onQueue(processQueue))
-        debug(.deviceManager, " CGM Manager with identifier \(manager.pluginIdentifier) wants deletion")
-        glucoseManager?.deleteGlucoseSource()
+        processQueue.async { [weak self] in
+            guard let self = self else { return }
+
+            dispatchPrecondition(condition: .onQueue(self.processQueue))
+
+            debug(.deviceManager, " CGM Manager with identifier \(manager.pluginIdentifier) wants deletion")
+            Task {
+                await self.glucoseManager?.deleteGlucoseSource()
+            }
+        }
     }
 
     func cgmManager(_: CGMManager, hasNew readingResult: CGMReadingResult) {
-        dispatchPrecondition(condition: .onQueue(processQueue))
-        promise?(readCGMResult(readingResult: readingResult))
-        debug(.deviceManager, "CGM PLUGIN - Direct return done")
+        processQueue.async { [weak self] in
+            guard let self = self else { return }
+
+            dispatchPrecondition(condition: .onQueue(self.processQueue))
+
+            self.promise?(self.readCGMResult(readingResult: readingResult))
+            debug(.deviceManager, "CGM PLUGIN - Direct return done")
+        }
     }
 
     func cgmManager(_: LoopKit.CGMManager, hasNew events: [LoopKit.PersistedCgmEvent]) {
-        dispatchPrecondition(condition: .onQueue(processQueue))
-        // TODO: Events in APS ?
-        // currently only display in log the date of the event
-        events.forEach { event in
-            debug(.deviceManager, "events from CGM at \(event.date)")
-
-            if event.type == .sensorStart {
-                self.glucoseManager?.removeCalibrations()
+        processQueue.async { [weak self] in
+            guard let self = self else { return }
+
+            dispatchPrecondition(condition: .onQueue(self.processQueue))
+
+            // TODO: Events in APS ?
+            // currently only display in log the date of the event
+            events.forEach { event in
+                debug(.deviceManager, "events from CGM at \(event.date)")
+
+                if event.type == .sensorStart {
+                    self.glucoseManager?.removeCalibrations()
+                }
             }
         }
     }
 
     func startDateToFilterNewData(for _: CGMManager) -> Date? {
-        dispatchPrecondition(condition: .onQueue(processQueue))
-        return glucoseStorage.lastGlucoseDate()
+        var date: Date?
+
+        processQueue.async { [weak self] in
+            guard let self = self else { return }
+
+            dispatchPrecondition(condition: .onQueue(self.processQueue))
+
+            date = glucoseStorage.lastGlucoseDate()
+        }
+
+        return date
     }
 
     func cgmManagerDidUpdateState(_ cgmManager: CGMManager) {
-        dispatchPrecondition(condition: .onQueue(processQueue))
+        processQueue.async { [weak self] in
+            guard let self = self else { return }
 
-        guard let fetchGlucoseManager = glucoseManager else {
-            debug(
-                .deviceManager,
-                "Could not gracefully unwrap FetchGlucoseManager upon observing LoopKit's cgmManagerDidUpdateState"
+            dispatchPrecondition(condition: .onQueue(self.processQueue))
+
+            guard let fetchGlucoseManager = self.glucoseManager else {
+                debug(
+                    .deviceManager,
+                    "Could not gracefully unwrap FetchGlucoseManager upon observing LoopKit's cgmManagerDidUpdateState"
+                )
+                return
+            }
+            // Adjust app-specific NS Upload setting value when CGM setting is changed
+            fetchGlucoseManager.settingsManager.settings.uploadGlucose = cgmManager.shouldSyncToRemoteService
+
+            fetchGlucoseManager.updateGlucoseSource(
+                cgmGlucoseSourceType: fetchGlucoseManager.settingsManager.settings.cgm,
+                cgmGlucosePluginId: fetchGlucoseManager.settingsManager.settings.cgmPluginIdentifier,
+                newManager: cgmManager as? CGMManagerUI
             )
-            return
         }
-        // Adjust app-specific NS Upload setting value when CGM setting is changed
-        fetchGlucoseManager.settingsManager.settings.uploadGlucose = cgmManager.shouldSyncToRemoteService
-
-        fetchGlucoseManager.updateGlucoseSource(
-            cgmGlucoseSourceType: fetchGlucoseManager.settingsManager.settings.cgm,
-            cgmGlucosePluginId: fetchGlucoseManager.settingsManager.settings.cgmPluginIdentifier,
-            newManager: cgmManager as? CGMManagerUI
-        )
     }
 
     func credentialStoragePrefix(for _: CGMManager) -> String {
@@ -161,8 +191,11 @@ extension PluginSource: CGMManagerDelegate {
 
     func cgmManager(_: CGMManager, didUpdate status: CGMManagerStatus) {
         debug(.deviceManager, "CGM Manager did update state to \(status)")
+        processQueue.async { [weak self] in
+            guard let self = self else { return }
+
+            dispatchPrecondition(condition: .onQueue(self.processQueue))
 
-        processQueue.async {
             if self.cgmHasValidSensorSession != status.hasValidSensorSession {
                 self.cgmHasValidSensorSession = status.hasValidSensorSession
             }

+ 15 - 9
Trio/Sources/APS/DeviceDataManager.swift

@@ -50,7 +50,7 @@ private let staticPumpManagersByIdentifier: [String: PumpManagerUI.Type] = [
 private let accessLock = NSRecursiveLock(label: "BaseDeviceDataManager.accessLock")
 
 final class BaseDeviceDataManager: DeviceDataManager, Injectable {
-    private let processQueue = DispatchQueue.markedQueue(label: "BaseDeviceDataManager.processQueue")
+    private let processQueue = DispatchQueue.markedQueue(label: "BaseDeviceDataManager.processQueue", qos: .userInitiated)
     @Injected() private var pumpHistoryStorage: PumpHistoryStorage!
     @Injected() var alertHistoryStorage: AlertHistoryStorage!
     @Injected() private var storage: FileStorage!
@@ -512,15 +512,21 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
     ) {
         dispatchPrecondition(condition: .onQueue(processQueue))
 
-        // filter buggy TBRs > maxBasal from MDT
-        let events = events.filter {
-            // type is optional...
-            guard let type = $0.type, type == .tempBasal else { return true }
-            return $0.dose?.unitsPerHour ?? 0 <= Double(settingsManager.pumpSettings.maxBasal)
+        Task {
+            do {
+                // filter buggy TBRs > maxBasal from MDT
+                let events = events.filter {
+                    // type is optional...
+                    guard let type = $0.type, type == .tempBasal else { return true }
+                    return $0.dose?.unitsPerHour ?? 0 <= Double(settingsManager.pumpSettings.maxBasal)
+                }
+                try await pumpHistoryStorage.storePumpEvents(events)
+                lastEventDate = events.last?.date
+                completion(nil)
+            } catch {
+                debug(.deviceManager, "\(DebuggingIdentifiers.failed) Failed to store pump events: \(error)")
+            }
         }
-        pumpHistoryStorage.storePumpEvents(events)
-        lastEventDate = events.last?.date
-        completion(nil)
     }
 
     func pumpManager(

+ 67 - 99
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -8,10 +8,8 @@ import Swinject
 import UIKit
 
 protocol FetchGlucoseManager: SourceInfoProvider {
-    func updateGlucoseStore(newBloodGlucose: [BloodGlucose])
-    func refreshCGM()
     func updateGlucoseSource(cgmGlucoseSourceType: CGMType, cgmGlucosePluginId: String, newManager: CGMManagerUI?)
-    func deleteGlucoseSource()
+    func deleteGlucoseSource() async
     func removeCalibrations()
     var glucoseSource: GlucoseSource! { get }
     var cgmManager: CGMManagerUI? { get }
@@ -77,18 +75,59 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         subscribe()
     }
 
+    /// The function used to start the timer sync - Function of the variable defined in config
+    private func subscribe() {
+        timer.publisher
+            .receive(on: processQueue)
+            .flatMap { [self] _ -> AnyPublisher<[BloodGlucose], Never> in
+                debug(.nightscout, "FetchGlucoseManager timer heartbeat")
+                if let glucoseSource = self.glucoseSource {
+                    return glucoseSource.fetch(self.timer).eraseToAnyPublisher()
+                } else {
+                    return Empty(completeImmediately: false).eraseToAnyPublisher()
+                }
+            }
+            .sink { glucose in
+                debug(.nightscout, "FetchGlucoseManager callback sensor")
+                Publishers.CombineLatest(
+                    Just(glucose),
+                    Just(self.glucoseStorage.syncDate())
+                )
+                .eraseToAnyPublisher()
+                .sink { newGlucose, syncDate in
+                    Task {
+                        do {
+                            try await self.glucoseStoreAndHeartDecision(
+                                syncDate: syncDate,
+                                glucose: newGlucose
+                            )
+                        } catch {
+                            debug(.deviceManager, "Failed to store glucose: \(error.localizedDescription)")
+                        }
+                    }
+                }
+                .store(in: &self.lifetime)
+            }
+            .store(in: &lifetime)
+        timer.fire()
+        timer.resume()
+    }
+
     var glucoseSource: GlucoseSource!
 
     func removeCalibrations() {
         calibrationService.removeAllCalibrations()
     }
 
-    func deleteGlucoseSource() {
+    @MainActor func deleteGlucoseSource() async {
         cgmManager = nil
+        glucoseSource = nil
         updateGlucoseSource(
-            cgmGlucoseSourceType: CGMType.none,
-            cgmGlucosePluginId: ""
+            cgmGlucoseSourceType: cgmDefaultModel.type,
+            cgmGlucosePluginId: cgmDefaultModel.id
         )
+        settingsManager.settings.cgm = cgmDefaultModel.type
+        settingsManager.settings.cgmPluginIdentifier = cgmDefaultModel.id
     }
 
     func saveConfigManager() {
@@ -128,13 +167,9 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         debug(.apsManager, "plugin : \(String(describing: cgmManager?.pluginIdentifier))")
 
         if let manager = newManager {
-            // If the pointer to manager is the *same* as our current `cgmManager`, skip re-init
-            if manager !== cgmManager {
-                // or do a more thorough check to see if it is the same class & state
-                removeCalibrations()
-                cgmManager = manager
-                glucoseSource = nil
-            }
+            cgmManager = manager
+            removeCalibrations()
+//            glucoseSource = nil
         } else if self.cgmGlucoseSourceType == .plugin, cgmManager == nil, let rawCGMManager = rawCGMManager {
             cgmManager = cgmManagerFromRawValue(rawCGMManager)
             updateManagerUnits(cgmManager)
@@ -171,32 +206,8 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         return Manager.init(rawState: rawState)
     }
 
-    /// function called when a callback is fired by CGM BLE - no more used
-    public func updateGlucoseStore(newBloodGlucose: [BloodGlucose]) {
-        let syncDate = glucoseStorage.syncDate()
-        debug(.deviceManager, "CGM BLE FETCHGLUCOSE  : SyncDate is \(syncDate)")
-        glucoseStoreAndHeartDecision(syncDate: syncDate, glucose: newBloodGlucose)
-    }
-
-    /// function to try to force the refresh of the CGM - generally provide by the pump heartbeat
-    public func refreshCGM() {
-        debug(.deviceManager, "refreshCGM by pump")
-
-        Publishers.CombineLatest(
-            Just(glucoseStorage.syncDate()),
-            glucoseSource.fetchIfNeeded()
-        )
-        .eraseToAnyPublisher()
-        .receive(on: processQueue)
-        .sink { syncDate, glucose in
-            debug(.nightscout, "refreshCGM FETCHGLUCOSE : SyncDate is \(syncDate)")
-            self.glucoseStoreAndHeartDecision(syncDate: syncDate, glucose: glucose)
-        }
-        .store(in: &lifetime)
-    }
-
-    private func fetchGlucose() -> [GlucoseStored]? {
-        CoreDataStack.shared.fetchEntities(
+    private func fetchGlucose() async throws -> [GlucoseStored]? {
+        try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: context,
             predicate: NSPredicate.predicateFor30MinAgo,
@@ -206,9 +217,13 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         ) as? [GlucoseStored]
     }
 
-    private func processGlucose() -> [BloodGlucose] {
-        context.performAndWait {
-            guard let results = fetchGlucose() else { return [] }
+    private func processGlucose() async throws -> [BloodGlucose] {
+        let results = try await fetchGlucose()
+
+        return try await context.perform {
+            guard let results else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
             return results.map { result in
                 BloodGlucose(
                     sgv: Int(result.glucose),
@@ -225,26 +240,19 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         }
     }
 
-    private func glucoseStoreAndHeartDecision(syncDate: Date, glucose: [BloodGlucose]) {
+    private func glucoseStoreAndHeartDecision(syncDate: Date, glucose: [BloodGlucose]) async throws {
         // calibration add if required only for sensor
         let newGlucose = overcalibrate(entries: glucose)
 
         var filteredByDate: [BloodGlucose] = []
         var filtered: [BloodGlucose] = []
 
-        // start background time extension
-        var backGroundFetchBGTaskID: UIBackgroundTaskIdentifier?
-        backGroundFetchBGTaskID = UIApplication.shared.beginBackgroundTask(withName: "save BG starting") {
-            guard let bg = backGroundFetchBGTaskID else { return }
-            UIApplication.shared.endBackgroundTask(bg)
-            backGroundFetchBGTaskID = .invalid
-        }
+        // Start background task
+        var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
+        backgroundTaskID = startBackgroundTask(withName: "Glucose Store and Heartbeat Decision")
 
         guard newGlucose.isNotEmpty else {
-            if let backgroundTask = backGroundFetchBGTaskID {
-                UIApplication.shared.endBackgroundTask(backgroundTask)
-                backGroundFetchBGTaskID = .invalid
-            }
+            endBackgroundTaskSafely(&backgroundTaskID, taskName: "Glucose Store and Heartbeat Decision")
             return
         }
 
@@ -252,11 +260,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         filtered = glucoseStorage.filterTooFrequentGlucose(filteredByDate, at: syncDate)
 
         guard filtered.isNotEmpty else {
-            // end of the Background tasks
-            if let backgroundTask = backGroundFetchBGTaskID {
-                UIApplication.shared.endBackgroundTask(backgroundTask)
-                backGroundFetchBGTaskID = .invalid
-            }
+            endBackgroundTaskSafely(&backgroundTaskID, taskName: "Glucose Store and Heartbeat Decision")
             return
         }
         debug(.deviceManager, "New glucose found")
@@ -264,7 +268,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         // filter the data if it is the case
         if settingsManager.settings.smoothGlucose {
             // limited to 30 min of old glucose data
-            let oldGlucoseValues = processGlucose()
+            let oldGlucoseValues = try await processGlucose()
 
             var smoothedValues = oldGlucoseValues + filtered
             // smooth with 3 repeats
@@ -275,47 +279,10 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
             filtered = smoothedValues.filter { $0.dateString > syncDate }
         }
 
-        glucoseStorage.storeGlucose(filtered)
-
+        try await glucoseStorage.storeGlucose(filtered)
         deviceDataManager.heartbeat(date: Date())
 
-        // End of the Background tasks
-        if let backgroundTask = backGroundFetchBGTaskID {
-            UIApplication.shared.endBackgroundTask(backgroundTask)
-            backGroundFetchBGTaskID = .invalid
-        }
-    }
-
-    /// The function used to start the timer sync - Function of the variable defined in config
-    private func subscribe() {
-        timer.publisher
-            .receive(on: processQueue)
-            .flatMap { [self] _ -> AnyPublisher<[BloodGlucose], Never> in
-                debug(.nightscout, "FetchGlucoseManager timer heartbeat")
-                if let glucoseSource = self.glucoseSource {
-                    return glucoseSource.fetch(self.timer).eraseToAnyPublisher()
-                } else {
-                    return Empty(completeImmediately: false).eraseToAnyPublisher()
-                }
-            }
-            .sink { glucose in
-                debug(.nightscout, "FetchGlucoseManager callback sensor")
-                Publishers.CombineLatest(
-                    Just(glucose),
-                    Just(self.glucoseStorage.syncDate())
-                )
-                .eraseToAnyPublisher()
-                .sink { newGlucose, syncDate in
-                    self.glucoseStoreAndHeartDecision(
-                        syncDate: syncDate,
-                        glucose: newGlucose
-                    )
-                }
-                .store(in: &self.lifetime)
-            }
-            .store(in: &lifetime)
-        timer.fire()
-        timer.resume()
+        endBackgroundTaskSafely(&backgroundTaskID, taskName: "Glucose Store and Heartbeat Decision")
     }
 
     func sourceInfo() -> [String: Any]? {
@@ -333,6 +300,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         if let overcalibration = overcalibration {
             return entries.map { entry in
                 var entry = entry
+                guard entry.glucose != nil else { return entry }
                 entry.glucose = Int(overcalibration(entry.glucose!))
                 entry.sgv = Int(overcalibration(entry.sgv!))
                 return entry

+ 39 - 35
Trio/Sources/APS/FetchTreatmentsManager.swift

@@ -30,48 +30,52 @@ final class BaseFetchTreatmentsManager: FetchTreatmentsManager, Injectable {
                 debug(.nightscout, "Start fetching carbs and temptargets")
 
                 Task {
-                    // Fetch carbs and temp targets concurrently
-                    async let carbs = self.nightscoutManager.fetchCarbs()
-                    async let tempTargets = self.nightscoutManager.fetchTempTargets()
+                    do {
+                        // Fetch carbs and temp targets concurrently
+                        async let carbs = self.nightscoutManager.fetchCarbs()
+                        async let tempTargets = self.nightscoutManager.fetchTempTargets()
 
-                    // Filter and store if not from "Trio"
-                    let filteredCarbs = await carbs.filter { $0.enteredBy != CarbsEntry.local }
-                    if filteredCarbs.isNotEmpty {
-                        await self.carbsStorage.storeCarbs(filteredCarbs, areFetchedFromRemote: true)
-                    }
+                        // Filter and store if not from "Trio"
+                        let filteredCarbs = await carbs.filter { $0.enteredBy != CarbsEntry.local }
+                        if filteredCarbs.isNotEmpty {
+                            try await self.carbsStorage.storeCarbs(filteredCarbs, areFetchedFromRemote: true)
+                        }
 
-                    // Filter and store if not from Trio
-                    let filteredTargets = await tempTargets.filter { $0.enteredBy != TempTarget.local }
-                    if filteredTargets.isNotEmpty {
-                        // Sort temp targets by creation date
-                        let sortedTargets = filteredTargets.sorted { $0.createdAt < $1.createdAt }
+                        // Filter and store if not from Trio
+                        let filteredTargets = await tempTargets.filter { $0.enteredBy != TempTarget.local }
+                        if filteredTargets.isNotEmpty {
+                            // Sort temp targets by creation date
+                            let sortedTargets = filteredTargets.sorted { $0.createdAt < $1.createdAt }
 
-                        // Iterate and store each temp target
-                        for (index, tempTarget) in sortedTargets.enumerated() {
-                            // Skip saving if a Temp Target with the same date already exists or it's a cancel target
-                            guard await !self.tempTargetsStorage.existsTempTarget(with: tempTarget.createdAt),
-                                  tempTarget.reason != TempTarget.cancel
-                            else {
-                                debug(
-                                    .nightscout,
-                                    "Skipping temp target with date: \(tempTarget.date ?? Date.distantPast)"
-                                )
-                                continue
-                            }
+                            // Iterate and store each temp target
+                            for (index, tempTarget) in sortedTargets.enumerated() {
+                                // Skip saving if a Temp Target with the same date already exists or it's a cancel target
+                                guard await !self.tempTargetsStorage.existsTempTarget(with: tempTarget.createdAt),
+                                      tempTarget.reason != TempTarget.cancel
+                                else {
+                                    debug(
+                                        .nightscout,
+                                        "Skipping temp target with date: \(tempTarget.date ?? Date.distantPast)"
+                                    )
+                                    continue
+                                }
 
-                            // Create a mutable copy and set enabled for the last temp target
-                            var mutableTempTarget = tempTarget
-                            mutableTempTarget.enabled = (index == sortedTargets.count - 1)
+                                // Create a mutable copy and set enabled for the last temp target
+                                var mutableTempTarget = tempTarget
+                                mutableTempTarget.enabled = (index == sortedTargets.count - 1)
 
-                            // Save to Core Data
-                            await self.tempTargetsStorage.storeTempTarget(tempTarget: mutableTempTarget)
-                        }
+                                // Save to Core Data
+                                try await self.tempTargetsStorage.storeTempTarget(tempTarget: mutableTempTarget)
+                            }
 
-                        // Save the temp targets to JSON so that they get used by oref
-                        self.tempTargetsStorage.saveTempTargetsToStorage(sortedTargets)
+                            // Save the temp targets to JSON so that they get used by oref
+                            self.tempTargetsStorage.saveTempTargetsToStorage(sortedTargets)
 
-                        // Update Adjustments View
-                        Foundation.NotificationCenter.default.post(name: .didUpdateTempTargetConfiguration, object: nil)
+                            // Update Adjustments View
+                            Foundation.NotificationCenter.default.post(name: .didUpdateTempTargetConfiguration, object: nil)
+                        }
+                    } catch {
+                        debug(.default, "\(DebuggingIdentifiers.failed) error in \(#file) \(#function): \(error)")
                     }
                 }
             }

+ 55 - 47
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -34,7 +34,6 @@ final class OpenAPS {
         await context.perform {
             let newOrefDetermination = OrefDetermination(context: self.context)
             newOrefDetermination.id = UUID()
-            newOrefDetermination.totalDailyDose = self.decimalToNSDecimalNumber(determination.tdd)
             newOrefDetermination.insulinSensitivity = self.decimalToNSDecimalNumber(determination.isf)
             newOrefDetermination.currentTarget = self.decimalToNSDecimalNumber(determination.current_target)
             newOrefDetermination.eventualBG = determination.eventualBG.map(NSDecimalNumber.init)
@@ -55,9 +54,6 @@ final class OpenAPS {
             newOrefDetermination.expectedDelta = self.decimalToNSDecimalNumber(determination.expectedDelta)
             newOrefDetermination.cob = Int16(Int(determination.cob ?? 0))
             newOrefDetermination.manualBolusErrorString = self.decimalToNSDecimalNumber(determination.manualBolusErrorString)
-            newOrefDetermination.tempBasal = determination.insulin?.temp_basal.map { NSDecimalNumber(decimal: $0) }
-            newOrefDetermination.scheduledBasal = determination.insulin?.scheduled_basal.map { NSDecimalNumber(decimal: $0) }
-            newOrefDetermination.bolus = determination.insulin?.bolus.map { NSDecimalNumber(decimal: $0) }
             newOrefDetermination.smbToDeliver = determination.units.map { NSDecimalNumber(decimal: $0) }
             newOrefDetermination.carbsRequired = Int16(Int(determination.carbsReq ?? 0))
             newOrefDetermination.isUploadedToNS = false
@@ -100,8 +96,8 @@ final class OpenAPS {
     }
 
     // fetch glucose to pass it to the meal function and to determine basal
-    private func fetchAndProcessGlucose() async -> String {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    private func fetchAndProcessGlucose() async throws -> String {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: context,
             predicate: NSPredicate.predicateForOneDayAgoInMinutes,
@@ -111,9 +107,9 @@ final class OpenAPS {
             batchSize: 24
         )
 
-        return await context.perform {
+        return try await context.perform {
             guard let glucoseResults = results as? [GlucoseStored] else {
-                return ""
+                throw CoreDataError.fetchError(function: #function, file: #file)
             }
 
             // convert to JSON
@@ -121,8 +117,8 @@ final class OpenAPS {
         }
     }
 
-    private func fetchAndProcessCarbs(additionalCarbs: Decimal? = nil) async -> String {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    private func fetchAndProcessCarbs(additionalCarbs: Decimal? = nil) async throws -> String {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             onContext: context,
             predicate: NSPredicate.predicateForOneDayAgo,
@@ -130,9 +126,9 @@ final class OpenAPS {
             ascending: false
         )
 
-        let json = await context.perform {
+        let json = try await context.perform {
             guard let carbResults = results as? [CarbEntryStored] else {
-                return ""
+                throw CoreDataError.fetchError(function: #function, file: #file)
             }
 
             var jsonArray = self.jsonConverter.convertToJSON(carbResults)
@@ -170,8 +166,8 @@ final class OpenAPS {
         return json
     }
 
-    private func fetchPumpHistoryObjectIDs() async -> [NSManagedObjectID]? {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    private func fetchPumpHistoryObjectIDs() async throws -> [NSManagedObjectID]? {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             onContext: context,
             predicate: NSPredicate.pumpHistoryLast1440Minutes,
@@ -180,9 +176,9 @@ final class OpenAPS {
             batchSize: 50
         )
 
-        return await context.perform {
+        return try await context.perform {
             guard let pumpEventResults = results as? [PumpEventStored] else {
-                return nil
+                throw CoreDataError.fetchError(function: #function, file: #file)
             }
 
             return pumpEventResults.map(\.objectID)
@@ -302,10 +298,10 @@ final class OpenAPS {
             reservoir,
             preferences
         ) = await (
-            parsePumpHistory(await pumpHistoryObjectIDs, simulatedBolusAmount: simulatedBolusAmount),
-            carbs,
-            glucose,
-            oref2,
+            try parsePumpHistory(await pumpHistoryObjectIDs, simulatedBolusAmount: simulatedBolusAmount),
+            try carbs,
+            try glucose,
+            try oref2,
             profileAsync,
             basalAsync,
             autosenseAsync,
@@ -366,12 +362,12 @@ final class OpenAPS {
 
             return determination
         } else {
-            return nil
+            throw APSError.apsError(message: "Determination is nil")
         }
     }
 
-    func oref2() async -> RawJSON {
-        await context.perform {
+    func oref2() async throws -> RawJSON {
+        try await context.perform {
             // Retrieve user preferences
             let userPreferences = self.storage.retrieve(OpenAPS.Settings.preferences, as: Preferences.self)
             let weightPercentage = userPreferences?.weightPercentage ?? 1.0
@@ -381,10 +377,10 @@ final class OpenAPS {
             // Fetch historical events for Total Daily Dose (TDD) calculation
             let tenDaysAgo = Date().addingTimeInterval(-10.days.timeInterval)
             let twoHoursAgo = Date().addingTimeInterval(-2.hours.timeInterval)
-            let historicalTDDData = self.fetchHistoricalTDDData(from: tenDaysAgo)
+            let historicalTDDData = try self.fetchHistoricalTDDData(from: tenDaysAgo)
 
             // Fetch the last active Override
-            let activeOverrides = self.fetchActiveOverrides()
+            let activeOverrides = try self.fetchActiveOverrides()
             let isOverrideActive = activeOverrides.first?.enabled ?? false
             let overridePercentage = Decimal(activeOverrides.first?.percentage ?? 100)
             let isOverrideIndefinite = activeOverrides.first?.indefinite ?? true
@@ -392,16 +388,16 @@ final class OpenAPS {
             let overrideTargetBG = activeOverrides.first?.target?.decimalValue ?? 0
 
             // Calculate averages for Total Daily Dose (TDD)
-            let totalTDD = historicalTDDData.compactMap { ($0["totalDailyDose"] as? NSDecimalNumber)?.decimalValue }.reduce(0, +)
+            let totalTDD = historicalTDDData.compactMap { ($0["total"] as? NSDecimalNumber)?.decimalValue }.reduce(0, +)
             let totalDaysCount = max(historicalTDDData.count, 1)
 
             // Fetch recent TDD data for the past two hours
-            let recentTDDData = historicalTDDData.filter { ($0["timestamp"] as? Date ?? Date()) >= twoHoursAgo }
+            let recentTDDData = historicalTDDData.filter { ($0["date"] as? Date ?? Date()) >= twoHoursAgo }
             let recentDataCount = max(recentTDDData.count, 1)
-            let recentTotalTDD = recentTDDData.compactMap { ($0["totalDailyDose"] as? NSDecimalNumber)?.decimalValue }
+            let recentTotalTDD = recentTDDData.compactMap { ($0["total"] as? NSDecimalNumber)?.decimalValue }
                 .reduce(0, +)
 
-            let currentTDD = historicalTDDData.last?["totalDailyDose"] as? Decimal ?? 0
+            let currentTDD = historicalTDDData.last?["total"] as? Decimal ?? 0
             let averageTDDLastTwoHours = recentTotalTDD / Decimal(recentDataCount)
             let averageTDDLastTenDays = totalTDD / Decimal(totalDaysCount)
             let weightedTDD = weightPercentage * averageTDDLastTwoHours + (1 - weightPercentage) * averageTDDLastTenDays
@@ -410,6 +406,7 @@ final class OpenAPS {
             let oref2Data = Oref2_variables(
                 average_total_data: currentTDD > 0 ? averageTDDLastTenDays : 0,
                 weightedAverage: currentTDD > 0 ? weightedTDD : 1,
+                currentTDD: currentTDD,
                 past2hoursAverage: currentTDD > 0 ? averageTDDLastTwoHours : 0,
                 date: Date(),
                 overridePercentage: overridePercentage,
@@ -448,9 +445,9 @@ final class OpenAPS {
 
         // Await the results of asynchronous tasks
         let (pumpHistoryJSON, carbsAsJSON, glucoseAsJSON, profile, basalProfile, tempTargets) = await (
-            parsePumpHistory(await pumpHistoryObjectIDs),
-            carbs,
-            glucose,
+            try parsePumpHistory(await pumpHistoryObjectIDs),
+            try carbs,
+            try glucose,
             getProfile,
             getBasalProfile,
             getTempTargets
@@ -477,7 +474,7 @@ final class OpenAPS {
         }
     }
 
-    func createProfiles(useSwiftOref: Bool) async {
+    func createProfiles(useSwiftOref: Bool) async throws {
         debug(.openAPS, "Start creating pump profile and user profile")
 
         // Load required settings and profiles asynchronously
@@ -507,9 +504,9 @@ final class OpenAPS {
         var adjustedPreferences = preferences
 
         // Check for active Temp Targets and adjust HBT if necessary
-        await context.perform {
+        try await context.perform {
             // Check if a Temp Target is active and if its HBT differs from user preferences
-            if let activeTempTarget = self.fetchActiveTempTargets().first,
+            if let activeTempTarget = try self.fetchActiveTempTargets().first,
                activeTempTarget.enabled,
                let activeHBT = activeTempTarget.halfBasalTarget?.decimalValue,
                activeHBT != defaultHalfBasalTarget
@@ -518,6 +515,11 @@ final class OpenAPS {
                 adjustedPreferences.halfBasalExerciseTarget = activeHBT
                 debug(.openAPS, "Updated halfBasalExerciseTarget to active Temp Target value: \(activeHBT)")
             }
+            // Overwrite the lowTTlowersSens if autosensMax does not support it
+            if preferences.lowTemptargetLowersSensitivity, preferences.autosensMax <= 1 {
+                adjustedPreferences.lowTemptargetLowersSensitivity = false
+                debug(.openAPS, "Setting lowTTlowersSens to false due to insufficient autosensMax: \(preferences.autosensMax)")
+            }
         }
 
         do {
@@ -557,11 +559,12 @@ final class OpenAPS {
                 .apsManager,
                 "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to create pump profile and normal profile: \(error)"
             )
+            throw error
         }
     }
 
     private func iob(pumphistory: JSON, profile: JSON, clock: JSON, autosens: JSON) async throws -> RawJSON {
-        await withCheckedContinuation { continuation in
+        try await withCheckedThrowingContinuation { continuation in
             jsWorker.inCommonContext { worker in
                 worker.evaluateBatch(scripts: [
                     Script(name: Prepare.log),
@@ -820,7 +823,12 @@ final class OpenAPS {
         }
 
         if let url = Foundation.Bundle.main.url(forResource: "javascript/\(name)", withExtension: "") {
-            return Script(name: name, body: try! String(contentsOf: url))
+            do {
+                let body = try String(contentsOf: url)
+                return Script(name: name, body: body)
+            } catch {
+                debug(.openAPS, "Failed to load script \(name): \(error)")
+            }
         }
 
         return nil
@@ -868,8 +876,8 @@ final class OpenAPS {
 
 // Non-Async fetch methods for oref2
 extension OpenAPS {
-    func fetchActiveTempTargets() -> [TempTargetStored] {
-        CoreDataStack.shared.fetchEntities(
+    func fetchActiveTempTargets() throws -> [TempTargetStored] {
+        try CoreDataStack.shared.fetchEntities(
             ofType: TempTargetStored.self,
             onContext: context,
             predicate: NSPredicate.lastActiveTempTarget,
@@ -879,8 +887,8 @@ extension OpenAPS {
         ) as? [TempTargetStored] ?? []
     }
 
-    func fetchActiveOverrides() -> [OverrideStored] {
-        CoreDataStack.shared.fetchEntities(
+    func fetchActiveOverrides() throws -> [OverrideStored] {
+        try CoreDataStack.shared.fetchEntities(
             ofType: OverrideStored.self,
             onContext: context,
             predicate: NSPredicate.lastActiveOverride,
@@ -890,14 +898,14 @@ extension OpenAPS {
         ) as? [OverrideStored] ?? []
     }
 
-    func fetchHistoricalTDDData(from date: Date) -> [[String: Any]] {
-        CoreDataStack.shared.fetchEntities(
-            ofType: OrefDetermination.self,
+    func fetchHistoricalTDDData(from date: Date) throws -> [[String: Any]] {
+        try CoreDataStack.shared.fetchEntities(
+            ofType: TDDStored.self,
             onContext: context,
-            predicate: NSPredicate(format: "timestamp > %@ AND totalDailyDose > 0", date as NSDate),
-            key: "timestamp",
+            predicate: NSPredicate(format: "date > %@ AND total > 0", date as NSDate),
+            key: "date",
             ascending: true,
-            propertiesToFetch: ["timestamp", "totalDailyDose"]
+            propertiesToFetch: ["date", "total"]
         ) as? [[String: Any]] ?? []
     }
 }

+ 51 - 49
Trio/Sources/APS/Storage/CarbsStorage.swift

@@ -10,14 +10,13 @@ protocol CarbsObserver {
 
 protocol CarbsStorage {
     var updatePublisher: AnyPublisher<Void, Never> { get }
-    func storeCarbs(_ carbs: [CarbsEntry], areFetchedFromRemote: Bool) async
+    func storeCarbs(_ carbs: [CarbsEntry], areFetchedFromRemote: Bool) async throws
     func deleteCarbsEntryStored(_ treatmentObjectID: NSManagedObjectID) async
     func syncDate() -> Date
-    func recent() -> [CarbsEntry]
-    func getCarbsNotYetUploadedToNightscout() async -> [NightscoutTreatment]
-    func getFPUsNotYetUploadedToNightscout() async -> [NightscoutTreatment]
-    func getCarbsNotYetUploadedToHealth() async -> [CarbsEntry]
-    func getCarbsNotYetUploadedToTidepool() async -> [CarbsEntry]
+    func getCarbsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment]
+    func getFPUsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment]
+    func getCarbsNotYetUploadedToHealth() async throws -> [CarbsEntry]
+    func getCarbsNotYetUploadedToTidepool() async throws -> [CarbsEntry]
 }
 
 final class BaseCarbsStorage: CarbsStorage, Injectable {
@@ -26,23 +25,24 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var settings: SettingsManager!
 
-    let coredataContext = CoreDataStack.shared.newTaskContext()
-
     private let updateSubject = PassthroughSubject<Void, Never>()
 
     var updatePublisher: AnyPublisher<Void, Never> {
         updateSubject.eraseToAnyPublisher()
     }
 
-    init(resolver: Resolver) {
+    private let context: NSManagedObjectContext
+
+    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
+        self.context = context ?? CoreDataStack.shared.newTaskContext()
         injectServices(resolver)
     }
 
-    func storeCarbs(_ entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
+    func storeCarbs(_ entries: [CarbsEntry], areFetchedFromRemote: Bool) async throws {
         var entriesToStore = entries
 
         if areFetchedFromRemote {
-            entriesToStore = await filterRemoteEntries(entries: entriesToStore)
+            entriesToStore = try await filterRemoteEntries(entries: entriesToStore)
         }
 
         // Check for FPU-only entries (fat/protein without carbs)
@@ -71,11 +71,11 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         await saveCarbEquivalents(entries: entriesToStore, areFetchedFromRemote: areFetchedFromRemote)
     }
 
-    private func filterRemoteEntries(entries: [CarbsEntry]) async -> [CarbsEntry] {
+    private func filterRemoteEntries(entries: [CarbsEntry]) async throws -> [CarbsEntry] {
         // Fetch only the date property from Core Data
-        guard let existing24hCarbEntries = await CoreDataStack.shared.fetchEntitiesAsync(
+        guard let existing24hCarbEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.predicateForOneDayAgo,
             key: "date",
             ascending: false,
@@ -217,8 +217,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     private func saveCarbsToCoreData(entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
         guard let entry = entries.last else { return }
 
-        await coredataContext.perform {
-            let newItem = CarbEntryStored(context: self.coredataContext)
+        await context.perform {
+            let newItem = CarbEntryStored(context: self.context)
             newItem.date = entry.actualDate ?? entry.createdAt
             newItem.carbs = Double(truncating: NSDecimalNumber(decimal: entry.carbs))
             newItem.fat = Double(truncating: NSDecimalNumber(decimal: entry.fat ?? 0))
@@ -235,8 +235,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             }
 
             do {
-                guard self.coredataContext.hasChanges else { return }
-                try self.coredataContext.save()
+                guard self.context.hasChanges else { return }
+                try self.context.save()
             } catch {
                 print(error.localizedDescription)
             }
@@ -264,9 +264,9 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             // do NOT set Health and Tidepool flags to ensure they will NOT be uploaded
             return false // return false to continue
         }
-        await coredataContext.perform {
+        await context.perform {
             do {
-                try self.coredataContext.execute(batchInsert)
+                try self.context.execute(batchInsert)
                 debugPrint("Carbs Storage: \(DebuggingIdentifiers.succeeded) saved fpus to core data")
 
                 // Notify subscriber in Home State Model to update the FPU Array
@@ -281,12 +281,12 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         Date().addingTimeInterval(-1.days.timeInterval)
     }
 
-    func recent() -> [CarbsEntry] {
-        storage.retrieve(OpenAPS.Monitor.carbHistory, as: [CarbsEntry].self)?.reversed() ?? []
-    }
-
     func deleteCarbsEntryStored(_ treatmentObjectID: NSManagedObjectID) async {
-        let taskContext = CoreDataStack.shared.newTaskContext()
+        // 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"
 
@@ -339,18 +339,18 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         }
     }
 
-    func getCarbsNotYetUploadedToNightscout() async -> [NightscoutTreatment] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func getCarbsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.carbsNotYetUploadedToNightscout,
             key: "date",
             ascending: false
         )
 
-        return await coredataContext.perform {
+        return try await context.perform {
             guard let carbEntries = results as? [CarbEntryStored] else {
-                return []
+                throw CoreDataError.fetchError(function: #function, file: #file)
             }
 
             return carbEntries.map { result in
@@ -378,17 +378,19 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         }
     }
 
-    func getFPUsNotYetUploadedToNightscout() async -> [NightscoutTreatment] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func getFPUsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.fpusNotYetUploadedToNightscout,
             key: "date",
             ascending: false
         )
 
-        return await coredataContext.perform {
-            guard let fpuEntries = results as? [CarbEntryStored] else { return [] }
+        return try await context.perform {
+            guard let fpuEntries = results as? [CarbEntryStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
             return fpuEntries.map { result in
                 NightscoutTreatment(
@@ -415,20 +417,20 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         }
     }
 
-    func getCarbsNotYetUploadedToHealth() async -> [CarbsEntry] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func getCarbsNotYetUploadedToHealth() async throws -> [CarbsEntry] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.carbsNotYetUploadedToHealth,
             key: "date",
             ascending: false
         )
 
-        guard let carbEntries = results as? [CarbEntryStored] else {
-            return []
-        }
+        return try await context.perform {
+            guard let carbEntries = results as? [CarbEntryStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
-        return await coredataContext.perform {
             return carbEntries.map { result in
                 CarbsEntry(
                     id: result.id?.uuidString,
@@ -446,20 +448,20 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         }
     }
 
-    func getCarbsNotYetUploadedToTidepool() async -> [CarbsEntry] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func getCarbsNotYetUploadedToTidepool() async throws -> [CarbsEntry] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.carbsNotYetUploadedToTidepool,
             key: "date",
             ascending: false
         )
 
-        guard let carbEntries = results as? [CarbEntryStored] else {
-            return []
-        }
+        return try await context.perform {
+            guard let carbEntries = results as? [CarbEntryStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
-        return await coredataContext.perform {
             return carbEntries.map { result in
                 CarbsEntry(
                     id: result.id?.uuidString,

+ 34 - 27
Trio/Sources/APS/Storage/ContactImageStorage.swift

@@ -26,36 +26,43 @@ final class BaseContactImageStorage: ContactImageStorage, Injectable {
     ///
     /// - Returns: An array of `ContactImageEntry` objects.
     func fetchContactImageEntries() async -> [ContactImageEntry] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: ContactImageEntryStored.self,
-            onContext: backgroundContext,
-            predicate: NSPredicate.all,
-            key: "hasHighContrast",
-            ascending: false
-        )
+        do {
+            let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+                ofType: ContactImageEntryStored.self,
+                onContext: backgroundContext,
+                predicate: NSPredicate.all,
+                key: "hasHighContrast",
+                ascending: false
+            )
 
-        return await backgroundContext.perform {
-            guard let fetchedContactImageEntries = results as? [ContactImageEntryStored] else { return [] }
+            return try await backgroundContext.perform {
+                guard let fetchedContactImageEntries = results as? [ContactImageEntryStored]
+                else { throw CoreDataError.fetchError(function: #function, file: #file)
+                }
 
-            return fetchedContactImageEntries.compactMap { entry in
-                ContactImageEntry(
-                    name: entry.name ?? "No name provided",
-                    layout: ContactImageLayout(rawValue: entry.layout ?? "Default") ?? .default,
-                    ring: ContactImageLargeRing(rawValue: entry.ring ?? "Hidden") ?? .none,
-                    primary: ContactImageValue(rawValue: entry.primary ?? "Glucose Reading") ?? .glucose,
-                    top: ContactImageValue(rawValue: entry.top ?? "None") ?? .none,
-                    bottom: ContactImageValue(rawValue: entry.bottom ?? "None") ?? .none,
-                    contactId: entry.contactId?.string,
-                    hasHighContrast: entry.hasHighContrast,
-                    ringWidth: ContactImageEntry.RingWidth(rawValue: Int(entry.ringWidth)) ?? .regular,
-                    ringGap: ContactImageEntry.RingGap(rawValue: Int(entry.ringGap)) ?? .small,
-                    fontSize: ContactImageEntry.FontSize(rawValue: Int(entry.fontSize)) ?? .regular,
-                    secondaryFontSize: ContactImageEntry.FontSize(rawValue: Int(entry.fontSizeSecondary)) ?? .small,
-                    fontWeight: Font.Weight.fromString(entry.fontWeight ?? "regular"),
-                    fontWidth: Font.Width.fromString(entry.fontWidth ?? "standard"),
-                    managedObjectID: entry.objectID
-                )
+                return fetchedContactImageEntries.compactMap { entry in
+                    ContactImageEntry(
+                        name: entry.name ?? String(localized: "No name provided"),
+                        layout: ContactImageLayout(rawValue: entry.layout ?? "Default") ?? .default,
+                        ring: ContactImageLargeRing(rawValue: entry.ring ?? "Hidden") ?? .none,
+                        primary: ContactImageValue(rawValue: entry.primary ?? "Glucose Reading") ?? .glucose,
+                        top: ContactImageValue(rawValue: entry.top ?? "None") ?? .none,
+                        bottom: ContactImageValue(rawValue: entry.bottom ?? "None") ?? .none,
+                        contactId: entry.contactId?.string,
+                        hasHighContrast: entry.hasHighContrast,
+                        ringWidth: ContactImageEntry.RingWidth(rawValue: Int(entry.ringWidth)) ?? .regular,
+                        ringGap: ContactImageEntry.RingGap(rawValue: Int(entry.ringGap)) ?? .small,
+                        fontSize: ContactImageEntry.FontSize(rawValue: Int(entry.fontSize)) ?? .regular,
+                        secondaryFontSize: ContactImageEntry.FontSize(rawValue: Int(entry.fontSizeSecondary)) ?? .small,
+                        fontWeight: Font.Weight.fromString(entry.fontWeight ?? "regular"),
+                        fontWidth: Font.Width.fromString(entry.fontWidth ?? "standard"),
+                        managedObjectID: entry.objectID
+                    )
+                }
             }
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Error fetching contact image entries: \(error.localizedDescription)")
+            return []
         }
     }
 

+ 51 - 18
Trio/Sources/APS/Storage/DeterminationStorage.swift

@@ -4,7 +4,7 @@ import Foundation
 import Swinject
 
 protocol DeterminationStorage {
-    func fetchLastDeterminationObjectID(predicate: NSPredicate) async -> [NSManagedObjectID]
+    func fetchLastDeterminationObjectID(predicate: NSPredicate) async throws -> [NSManagedObjectID]
     func getForecastIDs(for determinationID: NSManagedObjectID, in context: NSManagedObjectContext) async -> [NSManagedObjectID]
     func getForecastValueIDs(for forecastID: NSManagedObjectID, in context: NSManagedObjectContext) async -> [NSManagedObjectID]
     func fetchForecastObjects(
@@ -12,29 +12,33 @@ protocol DeterminationStorage {
         in context: NSManagedObjectContext
     ) async -> (UUID, Forecast?, [ForecastValue])
     func getOrefDeterminationNotYetUploadedToNightscout(_ determinationIds: [NSManagedObjectID]) async -> Determination?
+    func fetchForecastHierarchy(for determinationID: NSManagedObjectID, in context: NSManagedObjectContext)
+    async throws -> [(id: UUID, forecastID: NSManagedObjectID, forecastValueIDs: [NSManagedObjectID])]
 }
 
 final class BaseDeterminationStorage: DeterminationStorage, Injectable {
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-    private let backgroundContext = CoreDataStack.shared.newTaskContext()
+    private let context: NSManagedObjectContext
 
-    init(resolver: Resolver) {
+    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
+        self.context = context ?? CoreDataStack.shared.newTaskContext()
         injectServices(resolver)
     }
 
-    func fetchLastDeterminationObjectID(predicate: NSPredicate) async -> [NSManagedObjectID] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func fetchLastDeterminationObjectID(predicate: NSPredicate) async throws -> [NSManagedObjectID] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: predicate,
             key: "deliverAt",
             ascending: false,
             fetchLimit: 1
         )
 
-        return await backgroundContext.perform {
-            guard let fetchedResults = results as? [OrefDetermination] else { return [] }
-
+        return try await context.perform {
+            guard let fetchedResults = results as? [OrefDetermination] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
             return fetchedResults.map(\.objectID)
         }
     }
@@ -77,7 +81,7 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable {
         }
     }
 
-    // Fetch forecast value IDs for a given data set
+    // Fetch forecast objects for a given data set
     func fetchForecastObjects(
         for data: (id: UUID, forecastID: NSManagedObjectID, forecastValueIDs: [NSManagedObjectID]),
         in context: NSManagedObjectContext
@@ -112,19 +116,19 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable {
 
     // Convert NSSet to array of Ints for Predictions
     func parseForecastValues(ofType type: String, from determinationID: NSManagedObjectID) async -> [Int]? {
-        let forecastIDs = await getForecastIDs(for: determinationID, in: backgroundContext)
+        let forecastIDs = await getForecastIDs(for: determinationID, in: context)
 
         var forecastValuesList: [Int] = []
 
         for forecastID in forecastIDs {
-            await backgroundContext.perform {
-                if let forecast = try? self.backgroundContext.existingObject(with: forecastID) as? Forecast {
+            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.backgroundContext
+                            if let forecastValue = try? self.context
                                 .existingObject(with: forecastValueID) as? ForecastValue
                             {
                                 let forecastValueInt = Int(forecastValue.value)
@@ -153,9 +157,9 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable {
             uam: await parseForecastValues(ofType: "uam", from: determinationId)
         )
 
-        return await backgroundContext.perform {
+        return await context.perform {
             do {
-                let orefDetermination = try self.backgroundContext.existingObject(with: determinationId) as? OrefDetermination
+                let orefDetermination = try self.context.existingObject(with: determinationId) as? OrefDetermination
 
                 // Check if the fetched object is of the expected type
                 if let orefDetermination = orefDetermination {
@@ -178,8 +182,6 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable {
                         reservoir: self.decimal(from: orefDetermination.reservoir),
                         isf: self.decimal(from: orefDetermination.insulinSensitivity),
                         timestamp: orefDetermination.timestamp,
-                        tdd: self.decimal(from: orefDetermination.totalDailyDose),
-                        insulin: nil,
                         current_target: self.decimal(from: orefDetermination.currentTarget),
                         insulinForManualBolus: self.decimal(from: orefDetermination.insulinForManualBolus),
                         manualBolusErrorString: self.decimal(from: orefDetermination.manualBolusErrorString),
@@ -201,4 +203,35 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable {
             return result
         }
     }
+
+    func fetchForecastHierarchy(for determinationID: NSManagedObjectID, in context: NSManagedObjectContext)
+    async throws -> [(id: UUID, forecastID: NSManagedObjectID, forecastValueIDs: [NSManagedObjectID])]
+    {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: Forecast.self,
+            onContext: context,
+            predicate: NSPredicate(format: "orefDetermination = %@", determinationID),
+            key: "type",
+            ascending: true,
+            relationshipKeyPathsForPrefetching: ["forecastValues"]
+        )
+
+        var result: [(id: UUID, forecastID: NSManagedObjectID, forecastValueIDs: [NSManagedObjectID])] = []
+
+        await context.perform {
+            if let forecasts = results as? [Forecast] {
+                for forecast in forecasts {
+                    // Use the helper property that already sorts by index
+                    let sortedValues = forecast.forecastValuesArray
+                    result.append((
+                        id: UUID(),
+                        forecastID: forecast.objectID,
+                        forecastValueIDs: sortedValues.map(\.objectID)
+                    ))
+                }
+            }
+        }
+
+        return result
+    }
 }

+ 249 - 177
Trio/Sources/APS/Storage/GlucoseStorage.swift

@@ -9,20 +9,20 @@ import Swinject
 
 protocol GlucoseStorage {
     var updatePublisher: AnyPublisher<Void, Never> { get }
-    func storeGlucose(_ glucose: [BloodGlucose])
+    func storeGlucose(_ glucose: [BloodGlucose]) async throws
     func addManualGlucose(glucose: Int)
     func isGlucoseDataFresh(_ glucoseDate: Date?) -> Bool
     func syncDate() -> Date
     func filterTooFrequentGlucose(_ glucose: [BloodGlucose], at: Date) -> [BloodGlucose]
     func lastGlucoseDate() -> Date
     func isGlucoseFresh() -> Bool
-    func getGlucoseNotYetUploadedToNightscout() async -> [BloodGlucose]
-    func getCGMStateNotYetUploadedToNightscout() async -> [NightscoutTreatment]
-    func getManualGlucoseNotYetUploadedToNightscout() async -> [NightscoutTreatment]
-    func getGlucoseNotYetUploadedToHealth() async -> [BloodGlucose]
-    func getManualGlucoseNotYetUploadedToHealth() async -> [BloodGlucose]
-    func getGlucoseNotYetUploadedToTidepool() async -> [StoredGlucoseSample]
-    func getManualGlucoseNotYetUploadedToTidepool() async -> [StoredGlucoseSample]
+    func getGlucoseNotYetUploadedToNightscout() async throws -> [BloodGlucose]
+    func getCGMStateNotYetUploadedToNightscout() async throws -> [NightscoutTreatment]
+    func getManualGlucoseNotYetUploadedToNightscout() async throws -> [NightscoutTreatment]
+    func getGlucoseNotYetUploadedToHealth() async throws -> [BloodGlucose]
+    func getManualGlucoseNotYetUploadedToHealth() async throws -> [BloodGlucose]
+    func getGlucoseNotYetUploadedToTidepool() async throws -> [StoredGlucoseSample]
+    func getManualGlucoseNotYetUploadedToTidepool() async throws -> [StoredGlucoseSample]
     var alarm: GlucoseAlarm? { get }
     func deleteGlucose(_ treatmentObjectID: NSManagedObjectID) async
 }
@@ -33,8 +33,6 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var settingsManager: SettingsManager!
 
-    let coredataContext = CoreDataStack.shared.newTaskContext()
-
     private let updateSubject = PassthroughSubject<Void, Never>()
 
     var updatePublisher: AnyPublisher<Void, Never> {
@@ -45,7 +43,10 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         static let filterTime: TimeInterval = 3.5 * 60
     }
 
-    init(resolver: Resolver) {
+    private let context: NSManagedObjectContext
+
+    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
+        self.context = context ?? CoreDataStack.shared.newTaskContext()
         injectServices(resolver)
     }
 
@@ -60,123 +61,166 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         return formatter
     }
 
-    func storeGlucose(_ glucose: [BloodGlucose]) {
-        processQueue.sync {
-            self.coredataContext.perform {
-                let datesToCheck: Set<Date?> = Set(glucose.compactMap { $0.dateString as Date? })
-                let fetchRequest: NSFetchRequest<NSFetchRequestResult> = GlucoseStored.fetchRequest()
-                fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
-                    NSPredicate(format: "date IN %@", datesToCheck),
-                    NSPredicate.predicateForOneDayAgo
-                ])
-                fetchRequest.propertiesToFetch = ["date"]
-                fetchRequest.resultType = .dictionaryResultType
-
-                var existingDates = Set<Date>()
-                do {
-                    let results = try self.coredataContext.fetch(fetchRequest) as? [NSDictionary]
-                    existingDates = Set(results?.compactMap({ $0["date"] as? Date }) ?? [])
-                } catch {
-                    debugPrint("Failed to fetch existing glucose dates: \(error)")
-                }
+    func storeGlucose(_ glucose: [BloodGlucose]) async throws {
+        try await context.perform {
+            // Get new glucose values that don't exist yet
+            let newGlucose = self.filterNewGlucoseValues(glucose)
+            guard !newGlucose.isEmpty else { return }
 
-                var filteredGlucose = glucose.filter { !existingDates.contains($0.dateString) }
-
-                // prepare batch insert
-                let batchInsert = NSBatchInsertRequest(
-                    entity: GlucoseStored.entity(),
-                    managedObjectHandler: { (managedObject: NSManagedObject) -> Bool in
-                        guard let glucoseEntry = managedObject as? GlucoseStored, !filteredGlucose.isEmpty else {
-                            return true // Stop if there are no more items
-                        }
-                        let entry = filteredGlucose.removeFirst()
-                        glucoseEntry.id = UUID()
-                        glucoseEntry.glucose = Int16(entry.glucose ?? 0)
-                        glucoseEntry.date = entry.dateString
-                        glucoseEntry.direction = entry.direction?.rawValue
-                        glucoseEntry.isUploadedToNS = false /// the value is not uploaded to NS (yet)
-                        glucoseEntry.isUploadedToHealth = false /// the value is not uploaded to Health (yet)
-                        glucoseEntry.isUploadedToTidepool = false /// the value is not uploaded to Tidepool (yet)
-                        return false // Continue processing
-                    }
+            do {
+                // Store glucose values in Core Data
+                try self.storeGlucoseInCoreData(newGlucose)
+            } catch {
+                throw CoreDataError.creationError(
+                    function: #function,
+                    file: #fileID
                 )
+            }
+
+            // Store CGM state if needed
+            self.storeCGMState(glucose)
+        }
+    }
+
+    private func filterNewGlucoseValues(_ glucose: [BloodGlucose]) -> [BloodGlucose] {
+        let datesToCheck: Set<Date?> = Set(glucose.compactMap { $0.dateString as Date? })
+        let fetchRequest: NSFetchRequest<NSFetchRequestResult> = GlucoseStored.fetchRequest()
+        fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
+            NSPredicate(format: "date IN %@", datesToCheck),
+            NSPredicate.predicateForOneDayAgo
+        ])
+        fetchRequest.propertiesToFetch = ["date"]
+        fetchRequest.resultType = .dictionaryResultType
+
+        var existingDates = Set<Date>()
+        do {
+            let results = try context.fetch(fetchRequest) as? [NSDictionary]
+            existingDates = Set(results?.compactMap({ $0["date"] as? Date }) ?? [])
+        } catch {
+            debugPrint("Failed to fetch existing glucose dates: \(error)")
+        }
+
+        return glucose.filter { !existingDates.contains($0.dateString) }
+    }
+
+    private func storeGlucoseInCoreData(_ glucose: [BloodGlucose]) throws {
+        if glucose.count > 1 {
+            try storeGlucoseBatch(glucose)
+        } else {
+            try storeGlucoseRegular(glucose)
+        }
+    }
 
-                // process batch insert
-                do {
-                    try self.coredataContext.execute(batchInsert)
-
-                    // Notify subscribers that there is a new glucose value
-                    // We need to do this because the due to the batch insert there is no ManagedObjectContext notification
-                    self.updateSubject.send(())
-                } catch {
-                    debugPrint(
-                        "Glucose Storage: \(#function) \(DebuggingIdentifiers.failed) failed to execute batch insert: \(error)"
-                    )
+    private func storeGlucoseRegular(_ glucose: [BloodGlucose]) throws {
+        for entry in glucose {
+            let glucoseEntry = GlucoseStored(context: context)
+            configureGlucoseEntry(glucoseEntry, with: entry)
+        }
+
+        guard context.hasChanges else { return }
+        try context.save()
+    }
+
+    private func storeGlucoseBatch(_ glucose: [BloodGlucose]) throws {
+        var remainingGlucose = glucose
+        let batchInsert = NSBatchInsertRequest(
+            entity: GlucoseStored.entity(),
+            managedObjectHandler: { (managedObject: NSManagedObject) -> Bool in
+                guard let glucoseEntry = managedObject as? GlucoseStored,
+                      !remainingGlucose.isEmpty
+                else {
+                    return true
                 }
+                let entry = remainingGlucose.removeFirst()
+                self.configureGlucoseEntry(glucoseEntry, with: entry)
+                return false
+            }
+        )
+        try context.execute(batchInsert)
+        // Only send update for batch insert since regular save triggers CoreData notifications
+        updateSubject.send()
+    }
 
-                debug(.deviceManager, "start storage cgmState")
-                self.storage.transaction { storage in
-                    let file = OpenAPS.Monitor.cgmState
-                    var treatments = storage.retrieve(file, as: [NightscoutTreatment].self) ?? []
-                    var updated = false
-                    for x in glucose {
-                        debug(.deviceManager, "storeGlucose \(x)")
-                        guard let sessionStartDate = x.sessionStartDate else {
-                            continue
-                        }
-                        if let lastTreatment = treatments.last,
-                           let createdAt = lastTreatment.createdAt,
-                           // When a new Dexcom sensor is started, it produces multiple consecutive
-                           // startDates. Disambiguate them by only allowing a session start per minute.
-                           abs(createdAt.timeIntervalSince(sessionStartDate)) < TimeInterval(60)
-                        {
-                            continue
-                        }
-                        var notes = ""
-                        if let t = x.transmitterID {
-                            notes = t
-                        }
-                        if let a = x.activationDate {
-                            notes = "\(notes) activated on \(a)"
-                        }
-                        let treatment = NightscoutTreatment(
-                            duration: nil,
-                            rawDuration: nil,
-                            rawRate: nil,
-                            absolute: nil,
-                            rate: nil,
-                            eventType: .nsSensorChange,
-                            createdAt: sessionStartDate,
-                            enteredBy: NightscoutTreatment.local,
-                            bolus: nil,
-                            insulin: nil,
-                            notes: notes,
-                            carbs: nil,
-                            fat: nil,
-                            protein: nil,
-                            targetTop: nil,
-                            targetBottom: nil
-                        )
-                        debug(.deviceManager, "CGM sensor change \(treatment)")
-                        treatments.append(treatment)
-                        updated = true
-                    }
-                    if updated {
-                        // We have to keep quite a bit of history as sensors start only every 10 days.
-                        storage.save(
-                            treatments.filter
-                                { $0.createdAt != nil && $0.createdAt!.addingTimeInterval(30.days.timeInterval) > Date() },
-                            as: file
-                        )
-                    }
+    private func configureGlucoseEntry(_ entry: GlucoseStored, with glucose: BloodGlucose) {
+        entry.id = UUID()
+        entry.glucose = Int16(glucose.glucose ?? 0)
+        entry.date = glucose.dateString
+        entry.direction = glucose.direction?.rawValue
+        entry.isUploadedToNS = false
+        entry.isUploadedToHealth = false
+        entry.isUploadedToTidepool = false
+    }
+
+    private func storeCGMState(_ glucose: [BloodGlucose]) {
+        debug(.deviceManager, "start storage cgmState")
+        storage.transaction { storage in
+            let file = OpenAPS.Monitor.cgmState
+            var treatments = storage.retrieve(file, as: [NightscoutTreatment].self) ?? []
+            var updated = false
+
+            for x in glucose {
+                guard let sessionStartDate = x.sessionStartDate else { continue }
+
+                // Skip if we already have a recent treatment
+                if let lastTreatment = treatments.last,
+                   let createdAt = lastTreatment.createdAt,
+                   abs(createdAt.timeIntervalSince(sessionStartDate)) < TimeInterval(60)
+                {
+                    continue
                 }
+
+                let notes = createCGMStateNotes(transmitterID: x.transmitterID, activationDate: x.activationDate)
+                let treatment = createCGMStateTreatment(sessionStartDate: sessionStartDate, notes: notes)
+
+                debug(.deviceManager, "CGM sensor change \(treatment)")
+                treatments.append(treatment)
+                updated = true
             }
+
+            if updated {
+                storage.save(
+                    treatments.filter { $0.createdAt?.addingTimeInterval(30.days.timeInterval) ?? .distantPast > Date() },
+                    as: file
+                )
+            }
+        }
+    }
+
+    private func createCGMStateNotes(transmitterID: String?, activationDate: Date?) -> String {
+        var notes = ""
+        if let t = transmitterID {
+            notes = t
         }
+        if let a = activationDate {
+            notes = "\(notes) activated on \(a)"
+        }
+        return notes
+    }
+
+    private func createCGMStateTreatment(sessionStartDate: Date, notes: String) -> NightscoutTreatment {
+        NightscoutTreatment(
+            duration: nil,
+            rawDuration: nil,
+            rawRate: nil,
+            absolute: nil,
+            rate: nil,
+            eventType: .nsSensorChange,
+            createdAt: sessionStartDate,
+            enteredBy: NightscoutTreatment.local,
+            bolus: nil,
+            insulin: nil,
+            notes: notes,
+            carbs: nil,
+            fat: nil,
+            protein: nil,
+            targetTop: nil,
+            targetBottom: nil
+        )
     }
 
     func addManualGlucose(glucose: Int) {
-        coredataContext.perform {
-            let newItem = GlucoseStored(context: self.coredataContext)
+        context.perform {
+            let newItem = GlucoseStored(context: self.context)
             newItem.id = UUID()
             newItem.date = Date()
             newItem.glucose = Int16(glucose)
@@ -186,11 +230,11 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
             newItem.isUploadedToTidepool = false
 
             do {
-                guard self.coredataContext.hasChanges else { return }
-                try self.coredataContext.save()
+                guard self.context.hasChanges else { return }
+                try self.context.save()
 
                 // Glucose subscribers already listen to the update publisher, so call here to update glucose-related data.
-                self.updateSubject.send(())
+                self.updateSubject.send()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save manual glucose to Core Data with error: \(error)"
@@ -205,22 +249,30 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
 
     func syncDate() -> Date {
-        let fr = GlucoseStored.fetchRequest()
+        // Optimize fetch request to only get the date
+        let taskContext = CoreDataStack.shared.newTaskContext()
+        let fr = NSFetchRequest<NSDictionary>(entityName: "GlucoseStored")
         fr.predicate = NSPredicate.predicateForOneDayAgo
-        fr.sortDescriptors = [NSSortDescriptor(keyPath: \GlucoseStored.date, ascending: false)]
+        fr.propertiesToFetch = ["date"]
         fr.fetchLimit = 1
+        fr.resultType = .dictionaryResultType
+        fr.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
 
-        var date: Date?
-        coredataContext.performAndWait {
+        var fetchedDate: Date = .distantPast
+
+        taskContext.performAndWait {
             do {
-                let results = try self.coredataContext.fetch(fr)
-                date = results.first?.date
-            } catch let error as NSError {
-                print("Fetch error: \(DebuggingIdentifiers.failed) \(error.localizedDescription), \(error.userInfo)")
+                if let result = try taskContext.fetch(fr).first,
+                   let date = result["date"] as? Date
+                {
+                    fetchedDate = date
+                }
+            } catch {
+                debugPrint("Fetch error: \(DebuggingIdentifiers.failed) \(error.localizedDescription)")
             }
         }
 
-        return date ?? .distantPast
+        return fetchedDate
     }
 
     func lastGlucoseDate() -> Date {
@@ -230,9 +282,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         fr.fetchLimit = 1
 
         var date: Date?
-        coredataContext.performAndWait {
+        context.performAndWait {
             do {
-                let results = try self.coredataContext.fetch(fr)
+                let results = try self.context.fetch(fr)
                 date = results.first?.date
             } catch let error as NSError {
                 print("Fetch error: \(DebuggingIdentifiers.failed) \(error.localizedDescription), \(error.userInfo)")
@@ -262,11 +314,11 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         return filtered
     }
 
-    func fetchLatestGlucose() -> GlucoseStored? {
+    func fetchLatestGlucose() throws -> GlucoseStored? {
         let predicate = NSPredicate.predicateFor20MinAgo
-        return (CoreDataStack.shared.fetchEntities(
+        return (try CoreDataStack.shared.fetchEntities(
             ofType: GlucoseStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: predicate,
             key: "date",
             ascending: false,
@@ -276,17 +328,19 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
     // Fetch glucose that is not uploaded to Nightscout yet
     /// - Returns: Array of BloodGlucose to ensure the correct format for the NS Upload
-    func getGlucoseNotYetUploadedToNightscout() async -> [BloodGlucose] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func getGlucoseNotYetUploadedToNightscout() async throws -> [BloodGlucose] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.glucoseNotYetUploadedToNightscout,
             key: "date",
             ascending: false
         )
 
-        return await coredataContext.perform {
-            guard let fetchedResults = results as? [GlucoseStored] else { return [] }
+        return try await context.perform {
+            guard let fetchedResults = results as? [GlucoseStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
             return fetchedResults.map { result in
                 BloodGlucose(
@@ -307,18 +361,20 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
     // Fetch manual glucose that is not uploaded to Nightscout yet
     /// - Returns: Array of NightscoutTreatment to ensure the correct format for the NS Upload
-    func getManualGlucoseNotYetUploadedToNightscout() async -> [NightscoutTreatment] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func getManualGlucoseNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.manualGlucoseNotYetUploadedToNightscout,
             key: "date",
             ascending: false
         )
 
-        guard let fetchedResults = results as? [GlucoseStored] else { return [] }
+        return try await context.perform {
+            guard let fetchedResults = results as? [GlucoseStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
-        return await coredataContext.perform {
             return fetchedResults.map { result in
                 NightscoutTreatment(
                     duration: nil,
@@ -361,18 +417,20 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
     // Fetch glucose that is not uploaded to Nightscout yet
     /// - Returns: Array of BloodGlucose to ensure the correct format for the NS Upload
-    func getGlucoseNotYetUploadedToHealth() async -> [BloodGlucose] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func getGlucoseNotYetUploadedToHealth() async throws -> [BloodGlucose] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.glucoseNotYetUploadedToHealth,
             key: "date",
             ascending: false
         )
 
-        guard let fetchedResults = results as? [GlucoseStored] else { return [] }
+        return try await context.perform {
+            guard let fetchedResults = results as? [GlucoseStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
-        return await coredataContext.perform {
             return fetchedResults.map { result in
                 BloodGlucose(
                     _id: result.id?.uuidString ?? UUID().uuidString,
@@ -391,18 +449,20 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
     // Fetch manual glucose that is not uploaded to Nightscout yet
     /// - Returns: Array of NightscoutTreatment to ensure the correct format for the NS Upload
-    func getManualGlucoseNotYetUploadedToHealth() async -> [BloodGlucose] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func getManualGlucoseNotYetUploadedToHealth() async throws -> [BloodGlucose] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.manualGlucoseNotYetUploadedToHealth,
             key: "date",
             ascending: false
         )
 
-        guard let fetchedResults = results as? [GlucoseStored] else { return [] }
+        return try await context.perform {
+            guard let fetchedResults = results as? [GlucoseStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
-        return await coredataContext.perform {
             return fetchedResults.map { result in
                 BloodGlucose(
                     _id: result.id?.uuidString ?? UUID().uuidString,
@@ -421,18 +481,20 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
     // Fetch glucose that is not uploaded to Tidepool yet
     /// - Returns: Array of StoredGlucoseSample to ensure the correct format for Tidepool upload
-    func getGlucoseNotYetUploadedToTidepool() async -> [StoredGlucoseSample] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func getGlucoseNotYetUploadedToTidepool() async throws -> [StoredGlucoseSample] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.glucoseNotYetUploadedToTidepool,
             key: "date",
             ascending: false
         )
 
-        guard let fetchedResults = results as? [GlucoseStored] else { return [] }
+        return try await context.perform {
+            guard let fetchedResults = results as? [GlucoseStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
-        return await coredataContext.perform {
             return fetchedResults.map { result in
                 BloodGlucose(
                     _id: result.id?.uuidString ?? UUID().uuidString,
@@ -452,18 +514,20 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
     // Fetch manual glucose that is not uploaded to Tidepool yet
     /// - Returns: Array of StoredGlucoseSample to ensure the correct format for the Tidepool upload
-    func getManualGlucoseNotYetUploadedToTidepool() async -> [StoredGlucoseSample] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func getManualGlucoseNotYetUploadedToTidepool() async throws -> [StoredGlucoseSample] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.manualGlucoseNotYetUploadedToTidepool,
             key: "date",
             ascending: false
         )
 
-        guard let fetchedResults = results as? [GlucoseStored] else { return [] }
+        return try await context.perform {
+            guard let fetchedResults = results as? [GlucoseStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
-        return await coredataContext.perform {
             return fetchedResults.map { result in
                 BloodGlucose(
                     _id: result.id?.uuidString ?? UUID().uuidString,
@@ -481,7 +545,10 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
 
     func deleteGlucose(_ treatmentObjectID: NSManagedObjectID) async {
-        let taskContext = CoreDataStack.shared.newTaskContext()
+        // 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"
 
@@ -509,20 +576,25 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
     var alarm: GlucoseAlarm? {
         /// glucose can not be older than 20 minutes due to the predicate in the fetch request
-        coredataContext.performAndWait {
-            guard let glucose = fetchLatestGlucose() else { return nil }
+        context.performAndWait {
+            do {
+                guard let glucose = try fetchLatestGlucose() else { return nil }
 
-            let glucoseValue = glucose.glucose
+                let glucoseValue = glucose.glucose
 
-            if Decimal(glucoseValue) <= settingsManager.settings.lowGlucose {
-                return .low
-            }
+                if Decimal(glucoseValue) <= settingsManager.settings.lowGlucose {
+                    return .low
+                }
 
-            if Decimal(glucoseValue) >= settingsManager.settings.highGlucose {
-                return .high
-            }
+                if Decimal(glucoseValue) >= settingsManager.settings.highGlucose {
+                    return .high
+                }
 
-            return nil
+                return nil
+            } catch {
+                debugPrint("Error fetching latest glucose: \(error)")
+                return nil
+            }
         }
     }
 }
@@ -538,9 +610,9 @@ enum GlucoseAlarm {
     var displayName: String {
         switch self {
         case .high:
-            return NSLocalizedString("LOWALERT!", comment: "LOWALERT!")
+            return String(localized: "LOWALERT!", comment: "LOWALERT!")
         case .low:
-            return NSLocalizedString("HIGHALERT!", comment: "HIGHALERT!")
+            return String(localized: "HIGHALERT!", comment: "HIGHALERT!")
         }
     }
 }

+ 157 - 62
Trio/Sources/APS/Storage/OverrideStorage.swift

@@ -3,26 +3,32 @@ import Foundation
 import Swinject
 
 protocol OverrideStorage {
-    func fetchLastCreatedOverride() async -> [NSManagedObjectID]
-    func loadLatestOverrideConfigurations(fetchLimit: Int) async -> [NSManagedObjectID]
-    func fetchForOverridePresets() async -> [NSManagedObjectID]
+    func fetchLastCreatedOverride() async throws -> [NSManagedObjectID]
+    func loadLatestOverrideConfigurations(fetchLimit: Int) async throws -> [NSManagedObjectID]
+    func fetchForOverridePresets() async throws -> [NSManagedObjectID]
     func calculateTarget(override: OverrideStored) -> Decimal
-    func storeOverride(override: Override) async
+    func storeOverride(override: Override) async throws
     func copyRunningOverride(_ override: OverrideStored) async -> NSManagedObjectID
     func deleteOverridePreset(_ objectID: NSManagedObjectID) async
-    func getOverridesNotYetUploadedToNightscout() async -> [NightscoutExercise]
-    func getOverrideRunsNotYetUploadedToNightscout() async -> [NightscoutExercise]
-    func getPresetOverridesForNightscout() async -> [NightscoutPresetOverride]
-    func fetchLatestActiveOverride() async -> NSManagedObjectID?
+    func getOverridesNotYetUploadedToNightscout() async throws -> [NightscoutExercise]
+    func getOverrideRunsNotYetUploadedToNightscout() async throws -> [NightscoutExercise]
+    func checkIfShouldDeleteNightscoutOverrideEntry(
+        forCreatedAt createdAtString: String,
+        newDuration: Int?,
+        using nightscout: NightscoutAPI
+    ) async throws
+    func getPresetOverridesForNightscout() async throws -> [NightscoutPresetOverride]
+    func fetchLatestActiveOverride() async throws -> NSManagedObjectID?
 }
 
 final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     @Injected() private var settingsManager: SettingsManager!
 
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-    private let backgroundContext = CoreDataStack.shared.newTaskContext()
+    private let context: NSManagedObjectContext
 
-    init(resolver: Resolver) {
+    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
+        self.context = context ?? CoreDataStack.shared.newTaskContext()
         injectServices(resolver)
     }
 
@@ -34,10 +40,10 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
         return dateFormatter
     }
 
-    func fetchLastCreatedOverride() async -> [NSManagedObjectID] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func fetchLastCreatedOverride() async throws -> [NSManagedObjectID] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate(
                 format: "date >= %@",
                 Date.oneDayAgo as NSDate
@@ -47,42 +53,48 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
             fetchLimit: 1
         )
 
-        return await backgroundContext.perform {
-            guard let fetchedResults = results as? [OverrideStored] else { return [] }
+        return try await context.perform {
+            guard let fetchedResults = results as? [OverrideStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
             return fetchedResults.map(\.objectID)
         }
     }
 
-    func loadLatestOverrideConfigurations(fetchLimit: Int) async -> [NSManagedObjectID] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func loadLatestOverrideConfigurations(fetchLimit: Int) async throws -> [NSManagedObjectID] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.lastActiveOverride,
             key: "orderPosition",
             ascending: true,
             fetchLimit: fetchLimit
         )
 
-        return await backgroundContext.perform {
-            guard let fetchedResults = results as? [OverrideStored] else { return [] }
+        return try await context.perform {
+            guard let fetchedResults = results as? [OverrideStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
             return fetchedResults.map(\.objectID)
         }
     }
 
     /// Returns the NSManagedObjectID of the Override Presets
-    func fetchForOverridePresets() async -> [NSManagedObjectID] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func fetchForOverridePresets() async throws -> [NSManagedObjectID] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.allOverridePresets,
             key: "orderPosition",
             ascending: true
         )
 
-        return await backgroundContext.perform {
-            guard let fetchedResults = results as? [OverrideStored] else { return [] }
+        return try await context.perform {
+            guard let fetchedResults = results as? [OverrideStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
             return fetchedResults.map(\.objectID)
         }
@@ -95,15 +107,15 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
         return overrideTarget.decimalValue
     }
 
-    func storeOverride(override: Override) async {
+    func storeOverride(override: Override) async throws {
         var presetCount = -1
         if override.isPreset {
-            let presets = await fetchForOverridePresets()
+            let presets = try await fetchForOverridePresets()
             presetCount = presets.count
         }
 
-        await backgroundContext.perform {
-            let newOverride = OverrideStored(context: self.backgroundContext)
+        try await context.perform {
+            let newOverride = OverrideStored(context: self.context)
 
             // override key meta data
             if !override.name.isEmpty {
@@ -151,14 +163,8 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
                 newOverride.smbIsScheduledOff = false
             }
 
-            do {
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
-            } catch let error as NSError {
-                debugPrint(
-                    "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save Override Preset to Core Data with error: \(error.userInfo)"
-                )
-            }
+            guard self.context.hasChanges else { return }
+            try self.context.save()
         }
     }
 
@@ -200,23 +206,50 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
         return newOverride.objectID
     }
 
-    /// marked as MainActor to be able to publish changes from the background
     /// - Parameter: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
-    @MainActor func deleteOverridePreset(_ objectID: NSManagedObjectID) async {
-        await CoreDataStack.shared.deleteObject(identifiedBy: objectID)
+    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"
+        taskContext.transactionAuthor = "deleteOverride"
+
+        await taskContext.perform {
+            do {
+                guard let override = try taskContext.existingObject(with: objectID) as? OverrideStored else {
+                    debugPrint("Override for batch delete not found. \(DebuggingIdentifiers.failed)")
+                    return
+                }
+
+                taskContext.delete(override)
+
+                guard taskContext.hasChanges else { return }
+                try taskContext.save()
+
+                debugPrint(
+                    "OverrideStorage: \(#function) \(DebuggingIdentifiers.succeeded) deleted override from core data"
+                )
+            } catch {
+                debugPrint("\(DebuggingIdentifiers.failed) Error deleting override: \(error.localizedDescription)")
+            }
+        }
     }
 
-    func getOverridesNotYetUploadedToNightscout() async -> [NightscoutExercise] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func getOverridesNotYetUploadedToNightscout() async throws -> [NightscoutExercise] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.lastActiveAdjustmentNotYetUploadedToNightscout,
             key: "date",
             ascending: false
         )
 
-        return await backgroundContext.perform {
-            guard let fetchedOverrides = results as? [OverrideStored] else { return [] }
+        return try await context.perform {
+            guard let fetchedOverrides = results as? [OverrideStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
             return fetchedOverrides.map { override in
                 let duration = override.indefinite ? 43200 : override.duration ?? 0 // 43200 min = 30 days
@@ -225,17 +258,17 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
                     eventType: OverrideStored.EventType.nsExercise,
                     createdAt: override.date ?? Date(),
                     enteredBy: NightscoutExercise.local,
-                    notes: override.name ?? "Custom Override",
+                    notes: override.name ?? String(localized: "Custom Override"),
                     id: UUID(uuidString: override.id ?? UUID().uuidString)
                 )
             }
         }
     }
 
-    func getOverrideRunsNotYetUploadedToNightscout() async -> [NightscoutExercise] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func getOverrideRunsNotYetUploadedToNightscout() async throws -> [NightscoutExercise] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideRunStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate(
                 format: "startDate >= %@ AND isUploadedToNS == %@",
                 Date.oneDayAgo as NSDate,
@@ -245,8 +278,10 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
             ascending: false
         )
 
-        return await backgroundContext.perform {
-            guard let fetchedOverrideRuns = results as? [OverrideRunStored] else { return [] }
+        return try await context.perform {
+            guard let fetchedOverrideRuns = results as? [OverrideRunStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
             return fetchedOverrideRuns.map { overrideRun in
                 var durationInMinutes = (overrideRun.endDate?.timeIntervalSince(overrideRun.startDate ?? Date()) ?? 1) / 60
@@ -256,24 +291,82 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
                     eventType: OverrideStored.EventType.nsExercise,
                     createdAt: (overrideRun.startDate ?? overrideRun.override?.date) ?? Date(),
                     enteredBy: NightscoutExercise.local,
-                    notes: overrideRun.name ?? "Custom Override",
+                    notes: overrideRun.name ?? String(localized: "Custom Override"),
                     id: overrideRun.id
                 )
             }
         }
     }
 
-    func getPresetOverridesForNightscout() async -> [NightscoutPresetOverride] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    /// This check is needed to force re-rendering of overrides in the Nightscout main chart
+    /// if the override duration has changed (cancelled, customized or replaced with other override),
+    /// since just updating durations in existing entries doesn't trigger re-rendering.
+    func checkIfShouldDeleteNightscoutOverrideEntry(
+        forCreatedAt createdAtString: String,
+        newDuration: Int?,
+        using nightscout: NightscoutAPI
+    ) async throws {
+        let formatter = ISO8601DateFormatter()
+        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+
+        guard let jsonDate = formatter.date(from: createdAtString) else {
+            debug(.nightscout, "Could not parse override created_at string: \(createdAtString)")
+            return
+        }
+
+        /// Define a tolerance window (in seconds)
+        /// This is neccessary to handle small rounding/conversion time differences
+        /// when comparing dates between core data and NightscoutExercise json
+        let tolerance: TimeInterval = 0.1
+        let lowerBound = jsonDate.addingTimeInterval(-tolerance)
+        let upperBound = jsonDate.addingTimeInterval(tolerance)
+
+        /// 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 results = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: OverrideStored.self,
+            onContext: context,
+            predicate: predicate,
+            key: "date",
+            ascending: false
+        )
+
+        let storedOverride: NightscoutExercise? = await context.perform {
+            guard let fetched = results as? [OverrideStored],
+                  let record = fetched.first,
+                  let recordDate = record.date else { return nil }
+            let duration = record.indefinite ? 43200 : record.duration ?? 0
+            return NightscoutExercise(
+                duration: Int(truncating: duration),
+                eventType: OverrideStored.EventType.nsExercise,
+                createdAt: recordDate,
+                enteredBy: NightscoutExercise.local,
+                notes: record.name ?? String(localized: "Custom Override"),
+                id: UUID(uuidString: record.id ?? UUID().uuidString)
+            )
+        }
+
+        if let existing = storedOverride {
+            // Only delete existing nightscout entries if the durations differ.
+            if let existingDuration = existing.duration, let newDuration = newDuration, existingDuration != newDuration {
+                try await nightscout.deleteNightscoutOverride(withCreatedAt: createdAtString)
+            }
+        }
+    }
+
+    func getPresetOverridesForNightscout() async throws -> [NightscoutPresetOverride] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.allOverridePresets,
             key: "orderPosition",
             ascending: true
         )
 
-        return await backgroundContext.perform {
-            guard let fetchedResults = results as? [OverrideStored] else { return [] }
+        return try await context.perform {
+            guard let fetchedResults = results as? [OverrideStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
             return fetchedResults.map { overrideStored in
                 let duration = overrideStored.duration as? Decimal != 0 ? overrideStored.duration as? Decimal : nil
@@ -290,20 +383,22 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
         }
     }
 
-    func fetchLatestActiveOverride() async -> NSManagedObjectID? {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func fetchLatestActiveOverride() async throws -> NSManagedObjectID? {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.lastActiveOverride,
             key: "date",
             ascending: false,
             fetchLimit: 1
         )
 
-        return await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [OverrideStored],
                   let latestOverride = fetchedResults.first
-            else { return nil }
+            else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
             return latestOverride.objectID
         }

+ 224 - 203
Trio/Sources/APS/Storage/PumpHistoryStorage.swift

@@ -11,13 +11,12 @@ protocol PumpHistoryObserver {
 
 protocol PumpHistoryStorage {
     var updatePublisher: AnyPublisher<Void, Never> { get }
-    func storePumpEvents(_ events: [NewPumpEvent])
+    func getPumpHistory() async throws -> [PumpHistoryEvent]
+    func storePumpEvents(_ events: [NewPumpEvent]) async throws
     func storeExternalInsulinEvent(amount: Decimal, timestamp: Date) async
-    func recent() -> [PumpHistoryEvent]
-    func getPumpHistoryNotYetUploadedToNightscout() async -> [NightscoutTreatment]
-    func getPumpHistoryNotYetUploadedToHealth() async -> [PumpHistoryEvent]
-    func getPumpHistoryNotYetUploadedToTidepool() async -> [PumpHistoryEvent]
-    func deleteInsulin(at date: Date)
+    func getPumpHistoryNotYetUploadedToNightscout() async throws -> [NightscoutTreatment]
+    func getPumpHistoryNotYetUploadedToHealth() async throws -> [PumpHistoryEvent]
+    func getPumpHistoryNotYetUploadedToTidepool() async throws -> [PumpHistoryEvent]
 }
 
 final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
@@ -32,197 +31,194 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
         updateSubject.eraseToAnyPublisher()
     }
 
-    init(resolver: Resolver) {
+    private let context: NSManagedObjectContext
+
+    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
+        self.context = context ?? CoreDataStack.shared.newTaskContext()
         injectServices(resolver)
     }
 
     typealias PumpEvent = PumpEventStored.EventType
     typealias TempType = PumpEventStored.TempType
 
-    private let context = CoreDataStack.shared.newTaskContext()
-
     private func roundDose(_ dose: Double, toIncrement increment: Double) -> Decimal {
         let roundedValue = (dose / increment).rounded() * increment
         return Decimal(roundedValue)
     }
 
-    func storePumpEvents(_ events: [NewPumpEvent]) {
-        processQueue.async {
-            self.context.perform {
-                for event in events {
-                    // Fetch to filter out duplicates
-                    // TODO: - move this to the Core Data Class
-
-                    let existingEvents: [PumpEventStored] = CoreDataStack.shared.fetchEntities(
-                        ofType: PumpEventStored.self,
-                        onContext: self.context,
-                        predicate: NSPredicate.duplicateInLastHour(event.date),
-                        key: "timestamp",
-                        ascending: false,
-                        batchSize: 50
-                    ) as? [PumpEventStored] ?? []
-
-                    switch event.type {
-                    case .bolus:
-
-                        guard let dose = event.dose else { continue }
-                        let amount = self.roundDose(
-                            dose.unitsInDeliverableIncrements,
-                            toIncrement: Double(self.settings.preferences.bolusIncrement)
-                        )
+    func storePumpEvents(_ events: [NewPumpEvent]) async throws {
+        try await context.perform {
+            for event in events {
+                let existingEvents: [PumpEventStored] = try CoreDataStack.shared.fetchEntities(
+                    ofType: PumpEventStored.self,
+                    onContext: self.context,
+                    predicate: NSPredicate.duplicateInLastHour(event.date),
+                    key: "timestamp",
+                    ascending: false,
+                    batchSize: 50
+                ) as? [PumpEventStored] ?? []
+
+                switch event.type {
+                case .bolus:
 
-                        guard existingEvents.isEmpty else {
-                            // Duplicate found, do not store the event
-                            print("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
-
-                                        print("Updated existing event with smaller value: \(amount)")
-                                    }
+                    guard let dose = event.dose else { continue }
+                    let amount = self.roundDose(
+                        dose.unitsInDeliverableIncrements,
+                        toIncrement: Double(self.settings.preferences.bolusIncrement)
+                    )
+
+                    guard existingEvents.isEmpty else {
+                        // Duplicate found, do not store the event
+                        print("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
+
+                                    print("Updated existing event with smaller value: \(amount)")
                                 }
                             }
-                            continue
                         }
+                        continue
+                    }
 
-                        let newPumpEvent = PumpEventStored(context: self.context)
-                        newPumpEvent.id = UUID().uuidString
-                        // restrict entry to now or past
-                        newPumpEvent.timestamp = event.date > Date() ? Date() : event.date
-                        newPumpEvent.type = PumpEvent.bolus.rawValue
-                        newPumpEvent.isUploadedToNS = false
-                        newPumpEvent.isUploadedToHealth = false
-                        newPumpEvent.isUploadedToTidepool = false
-
-                        let newBolusEntry = BolusStored(context: self.context)
-                        newBolusEntry.pumpEvent = newPumpEvent
-                        newBolusEntry.amount = NSDecimalNumber(decimal: amount)
-                        newBolusEntry.isExternal = dose.manuallyEntered
-                        newBolusEntry.isSMB = dose.automatic ?? true
-
-                    case .tempBasal:
-                        guard let dose = event.dose else { continue }
-
-                        guard existingEvents.isEmpty else {
-                            // Duplicate found, do not store the event
-                            print("Duplicate event found with timestamp: \(event.date)")
-                            continue
-                        }
+                    let newPumpEvent = PumpEventStored(context: self.context)
+                    newPumpEvent.id = UUID().uuidString
+                    // restrict entry to now or past
+                    newPumpEvent.timestamp = event.date > Date() ? Date() : event.date
+                    newPumpEvent.type = PumpEvent.bolus.rawValue
+                    newPumpEvent.isUploadedToNS = false
+                    newPumpEvent.isUploadedToHealth = false
+                    newPumpEvent.isUploadedToTidepool = false
+
+                    let newBolusEntry = BolusStored(context: self.context)
+                    newBolusEntry.pumpEvent = newPumpEvent
+                    newBolusEntry.amount = NSDecimalNumber(decimal: amount)
+                    newBolusEntry.isExternal = dose.manuallyEntered
+                    newBolusEntry.isSMB = dose.automatic ?? true
+
+                case .tempBasal:
+                    guard let dose = event.dose else { continue }
+
+                    guard existingEvents.isEmpty else {
+                        // Duplicate found, do not store the event
+                        print("Duplicate event found with timestamp: \(event.date)")
+                        continue
+                    }
 
-                        let rate = Decimal(dose.unitsPerHour)
-                        let minutes = (dose.endDate - dose.startDate).timeInterval / 60
-                        let delivered = dose.deliveredUnits
-                        let date = event.date
-
-                        let isCancel = delivered != nil
-                        guard !isCancel else { continue }
-
-                        let newPumpEvent = PumpEventStored(context: self.context)
-                        newPumpEvent.id = UUID().uuidString
-                        newPumpEvent.timestamp = date
-                        newPumpEvent.type = PumpEvent.tempBasal.rawValue
-                        newPumpEvent.isUploadedToNS = false
-                        newPumpEvent.isUploadedToHealth = false
-                        newPumpEvent.isUploadedToTidepool = false
-
-                        let newTempBasal = TempBasalStored(context: self.context)
-                        newTempBasal.pumpEvent = newPumpEvent
-                        newTempBasal.duration = Int16(round(minutes))
-                        newTempBasal.rate = rate as NSDecimalNumber
-                        newTempBasal.tempType = TempType.absolute.rawValue
-
-                    case .suspend:
-                        guard existingEvents.isEmpty else {
-                            // Duplicate found, do not store the event
-                            print("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
-
-                    case .resume:
-                        guard existingEvents.isEmpty else {
-                            // Duplicate found, do not store the event
-                            print("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
-
-                    case .rewind:
-                        guard existingEvents.isEmpty else {
-                            // Duplicate found, do not store the event
-                            print("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
-
-                    case .prime:
-                        guard existingEvents.isEmpty else {
-                            // Duplicate found, do not store the event
-                            print("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
-
-                    case .alarm:
-                        guard existingEvents.isEmpty else {
-                            // Duplicate found, do not store the event
-                            print("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
-
-                    default:
+                    let rate = Decimal(dose.unitsPerHour)
+                    let minutes = (dose.endDate - dose.startDate).timeInterval / 60
+                    let delivered = dose.deliveredUnits
+                    let date = event.date
+
+                    let isCancel = delivered != nil
+                    guard !isCancel else { continue }
+
+                    let newPumpEvent = PumpEventStored(context: self.context)
+                    newPumpEvent.id = UUID().uuidString
+                    newPumpEvent.timestamp = date
+                    newPumpEvent.type = PumpEvent.tempBasal.rawValue
+                    newPumpEvent.isUploadedToNS = false
+                    newPumpEvent.isUploadedToHealth = false
+                    newPumpEvent.isUploadedToTidepool = false
+
+                    let newTempBasal = TempBasalStored(context: self.context)
+                    newTempBasal.pumpEvent = newPumpEvent
+                    newTempBasal.duration = Int16(round(minutes))
+                    newTempBasal.rate = rate as NSDecimalNumber
+                    newTempBasal.tempType = TempType.absolute.rawValue
+
+                case .suspend:
+                    guard existingEvents.isEmpty else {
+                        // Duplicate found, do not store the event
+                        print("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
+
+                case .resume:
+                    guard existingEvents.isEmpty else {
+                        // Duplicate found, do not store the event
+                        print("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
+
+                case .rewind:
+                    guard existingEvents.isEmpty else {
+                        // Duplicate found, do not store the event
+                        print("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
+
+                case .prime:
+                    guard existingEvents.isEmpty else {
+                        // Duplicate found, do not store the event
+                        print("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
+
+                case .alarm:
+                    guard existingEvents.isEmpty else {
+                        // Duplicate found, do not store the event
+                        print("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
+
+                default:
+                    continue
                 }
+            }
 
-                do {
-                    guard self.context.hasChanges else { return }
-                    try self.context.save()
+            do {
+                guard self.context.hasChanges else { return }
+                try self.context.save()
 
-                    self.updateSubject.send(())
-                    debugPrint("\(DebuggingIdentifiers.succeeded) stored pump events in Core Data")
-                } catch let error as NSError {
-                    debugPrint("\(DebuggingIdentifiers.failed) failed to store pump events with error: \(error.userInfo)")
-                }
+                self.updateSubject.send(())
+                debugPrint("\(DebuggingIdentifiers.succeeded) stored pump events in Core Data")
+            } catch let error as NSError {
+                debugPrint("\(DebuggingIdentifiers.failed) failed to store pump events with error: \(error.userInfo)")
+                throw error
             }
         }
     }
@@ -258,21 +254,40 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
         }
     }
 
-    func recent() -> [PumpHistoryEvent] {
-        storage.retrieve(OpenAPS.Monitor.pumpHistory, as: [PumpHistoryEvent].self)?.reversed() ?? []
-    }
+    func getPumpHistory() async throws -> [PumpHistoryEvent] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: context,
+            predicate: NSPredicate.pumpHistoryLast24h,
+            key: "timestamp",
+            ascending: false,
+            fetchLimit: 288
+        )
 
-    func deleteInsulin(at date: Date) {
-        processQueue.sync {
-            var allValues = storage.retrieve(OpenAPS.Monitor.pumpHistory, as: [PumpHistoryEvent].self) ?? []
-            guard let entryIndex = allValues.firstIndex(where: { $0.timestamp == date }) else {
-                return
-            }
-            allValues.remove(at: entryIndex)
-            storage.save(allValues, as: OpenAPS.Monitor.pumpHistory)
-            broadcaster.notify(PumpHistoryObserver.self, on: processQueue) {
-                $0.pumpHistoryDidUpdate(allValues)
-            }
+        return await context.perform {
+            guard let fetchedPumpEvents = results as? [PumpEventStored] else { return [] }
+
+            return fetchedPumpEvents.map { event in
+                switch event.type {
+                case PumpEventStored.EventType.bolus.rawValue:
+                    return PumpHistoryEvent(
+                        id: event.id ?? UUID().uuidString,
+                        type: .bolus,
+                        timestamp: event.timestamp ?? Date(),
+                        amount: event.bolus?.amount as Decimal?
+                    )
+                case PumpEventStored.EventType.tempBasal.rawValue:
+                    return PumpHistoryEvent(
+                        id: event.id ?? UUID().uuidString,
+                        type: .tempBasal,
+                        timestamp: event.timestamp ?? Date(),
+                        amount: event.tempBasal?.rate as Decimal?,
+                        duration: Int(event.tempBasal?.duration ?? 0)
+                    )
+                default:
+                    return nil
+                }
+            }.compactMap { $0 }
         }
     }
 
@@ -286,8 +301,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
         return PumpEventStored.EventType(rawValue: event.type!) ?? PumpEventStored.EventType.bolus
     }
 
-    func getPumpHistoryNotYetUploadedToNightscout() async -> [NightscoutTreatment] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func getPumpHistoryNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             onContext: context,
             predicate: NSPredicate.pumpEventsNotYetUploadedToNightscout,
@@ -295,8 +310,10 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
             ascending: false
         )
 
-        return await context.perform { [self] in
-            guard let fetchedPumpEvents = results as? [PumpEventStored] else { return [] }
+        return try await context.perform { [self] in
+            guard let fetchedPumpEvents = results as? [PumpEventStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
             return fetchedPumpEvents.map { event in
                 switch event.type {
@@ -445,8 +462,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
         }
     }
 
-    func getPumpHistoryNotYetUploadedToHealth() async -> [PumpHistoryEvent] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func getPumpHistoryNotYetUploadedToHealth() async throws -> [PumpHistoryEvent] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             onContext: context,
             predicate: NSPredicate.pumpEventsNotYetUploadedToHealth,
@@ -454,10 +471,12 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
             ascending: false
         )
 
-        guard let fetchedPumpEvents = results as? [PumpEventStored] else { return [] }
+        return try await context.perform {
+            guard let fetchedPumpEvents = results as? [PumpEventStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
-        return await context.perform {
-            fetchedPumpEvents.map { event in
+            return fetchedPumpEvents.map { event in
                 switch event.type {
                 case PumpEvent.bolus.rawValue:
                     return PumpHistoryEvent(
@@ -487,8 +506,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
         }
     }
 
-    func getPumpHistoryNotYetUploadedToTidepool() async -> [PumpHistoryEvent] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func getPumpHistoryNotYetUploadedToTidepool() async throws -> [PumpHistoryEvent] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             onContext: context,
             predicate: NSPredicate.pumpEventsNotYetUploadedToTidepool,
@@ -496,10 +515,12 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
             ascending: false
         )
 
-        guard let fetchedPumpEvents = results as? [PumpEventStored] else { return [] }
+        return try await context.perform {
+            guard let fetchedPumpEvents = results as? [PumpEventStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
-        return await context.perform {
-            fetchedPumpEvents.map { event in
+            return fetchedPumpEvents.map { event in
                 switch event.type {
                 case PumpEvent.bolus.rawValue:
                     return PumpHistoryEvent(

+ 661 - 0
Trio/Sources/APS/Storage/TDDStorage.swift

@@ -0,0 +1,661 @@
+import Foundation
+import LoopKitUI
+import Swinject
+
+protocol TDDStorage {
+    func calculateTDD(
+        pumpManager: any PumpManagerUI,
+        pumpHistory: [PumpHistoryEvent],
+        basalProfile: [BasalProfileEntry]
+    ) async throws
+        -> TDDResult
+    func storeTDD(_ tddResult: TDDResult) async
+}
+
+/// Structure containing the results of TDD calculations
+struct TDDResult {
+    let total: Decimal
+    let bolus: Decimal
+    let tempBasal: Decimal
+    let scheduledBasal: Decimal
+    let weightedAverage: Decimal?
+    let hoursOfData: Double
+}
+
+/// Implementation of the TDD Calculator
+final class BaseTDDStorage: TDDStorage, Injectable {
+    @Injected() private var storage: FileStorage!
+
+    init(resolver: Resolver) {
+        injectServices(resolver)
+    }
+
+    private let privateContext = CoreDataStack.shared.newTaskContext()
+
+    /// Main function to calculate TDD from pump history and basal profile
+    /// - Parameters:
+    ///   - pumpManager: Representation of paired pump's PumpManagerUI
+    ///   - pumpHistory: Array of pump history events
+    ///   - basalProfile: Array of basal profile entries
+    /// - Returns: TDDResult containing all calculated values
+    func calculateTDD(
+        pumpManager: any PumpManagerUI,
+        pumpHistory: [PumpHistoryEvent],
+        basalProfile: [BasalProfileEntry]
+    ) async throws -> TDDResult {
+        debug(.apsManager, "Starting TDD calculation with \(pumpHistory.count) pump events")
+
+        // Log the first and last pump history events if available
+        let earliestEvent: String
+        let latestEvent: String
+
+        // We fetch descending, so invert logic
+        if let firstEvent = pumpHistory.last, let lastEvent = pumpHistory.first {
+            earliestEvent = "Type: \(firstEvent.type), Timestamp: \(firstEvent.timestamp.ISO8601Format())"
+            latestEvent = "Type: \(lastEvent.type), Timestamp: \(lastEvent.timestamp.ISO8601Format())"
+        } else {
+            earliestEvent = "No events available"
+            latestEvent = "No events available"
+            debug(.apsManager, "No pump history events available for logging.")
+        }
+
+        // Group events by type once to avoid multiple filters
+        let groupedEvents = Dictionary(grouping: pumpHistory, by: { $0.type })
+        let bolusEvents = groupedEvents[.bolus] ?? []
+        let tempBasalEvents = groupedEvents[.tempBasal] ?? []
+        let pumpSuspendEvents = groupedEvents[.pumpSuspend] ?? []
+        let pumpResumeEvents = groupedEvents[.pumpResume] ?? []
+
+        // Create pairs of suspend + resume events
+        let suspendResumePairs = zip(pumpSuspendEvents, pumpResumeEvents).filter { suspend, resume in
+            resume.timestamp > suspend.timestamp
+        }
+
+        // Calculate all components concurrently
+        async let pumpDataHours = calculatePumpDataHours(pumpHistory)
+        async let bolusInsulin = calculateBolusInsulin(bolusEvents)
+        let gaps = findBasalGaps(in: tempBasalEvents)
+        async let scheduledBasalInsulin = !gaps.isEmpty ? calculateScheduledBasalInsulin(
+            gaps: gaps,
+            profile: basalProfile,
+            roundToSupportedBasalRate: pumpManager.roundToSupportedBasalRate
+        ) : 0
+        async let tempBasalInsulin = calculateTempBasalInsulin(
+            tempBasalEvents, suspendResumePairs: suspendResumePairs,
+            roundToSupportedBasalRate: pumpManager.roundToSupportedBasalRate
+        )
+        async let weightedAverage = calculateWeightedAverage()
+
+        // Await all concurrent calculations
+        let (hours, bolus, scheduled, temp, weighted) = try await (
+            pumpDataHours,
+            bolusInsulin,
+            scheduledBasalInsulin,
+            tempBasalInsulin,
+            weightedAverage
+        )
+
+        // Total insulin calculation
+        let total = bolus + temp + scheduled
+
+        // Safeguard against division by zero
+        let percentage: (Decimal, Decimal) -> String = { part, total in
+            total > 0 ? String(format: "%.2f", NSDecimalNumber(decimal: (part / total * 100).rounded(toPlaces: 2)).doubleValue) :
+                "0.00"
+        }
+
+        // Store log strings in variables to avoid Xcode auto formatter from breaking up the lines in log statement
+        let totalString = String(format: "%.2f", NSDecimalNumber(decimal: total.rounded(toPlaces: 2)).doubleValue)
+        let bolusString = String(format: "%.2f", NSDecimalNumber(decimal: bolus.rounded(toPlaces: 2)).doubleValue)
+        let tempBasalString = String(format: "%.2f", NSDecimalNumber(decimal: temp.rounded(toPlaces: 2)).doubleValue)
+        let scheduledBasalString = String(format: "%.2f", NSDecimalNumber(decimal: scheduled.rounded(toPlaces: 2)).doubleValue)
+        let weightedAvgString = String(format: "%.2f", NSDecimalNumber(decimal: weighted?.rounded(toPlaces: 2) ?? 0).doubleValue)
+        let hoursString = String(format: "%.5f", NSDecimalNumber(decimal: Decimal(hours).truncated(toPlaces: 5)).doubleValue)
+
+        debug(.apsManager, """
+        TDD Summary:
+        +-------------------+-----------+-----------+
+        | Type\t\t\t\t| Amount U\t| Percent %\t|
+        +-------------------+-----------+-----------+
+        | Total\t\t\t\t| \(totalString)\t\t| \t\t\t|
+        | Bolus\t\t\t\t| \(bolusString)\t\t| \(percentage(bolus, total))\t\t|
+        | Temp Basal\t\t| \(tempBasalString)\t\t| \(percentage(temp, total))\t\t|
+        | Scheduled Basal\t| \(scheduledBasalString)\t\t| \(percentage(scheduled, total))\t\t|
+        | Weighted Average\t| \(weightedAvgString)\t\t| \t\t\t|
+        +-------------------+-----------+-----------+
+        - Hours of Data: \(hoursString)
+        - Earliest Event: \(earliestEvent)
+        - Latest Event: \(latestEvent)
+        """)
+
+        // Return calculated TDDResult
+        return TDDResult(
+            total: total,
+            bolus: bolus,
+            tempBasal: temp,
+            scheduledBasal: scheduled,
+            weightedAverage: weighted,
+            hoursOfData: hours
+        )
+    }
+
+    /// Stores the Total Daily Dose (TDD) result in Core Data
+    /// - Parameter tddResult: The TDD result to store, containing total insulin, bolus, temp basal, scheduled basal and weighted average
+    func storeTDD(_ tddResult: TDDResult) async {
+        await privateContext.perform {
+            let tddStored = TDDStored(context: self.privateContext)
+            tddStored.id = UUID()
+            tddStored.date = Date()
+            tddStored.total = NSDecimalNumber(decimal: tddResult.total)
+            tddStored.bolus = NSDecimalNumber(decimal: tddResult.bolus)
+            tddStored.tempBasal = NSDecimalNumber(decimal: tddResult.tempBasal)
+            tddStored.scheduledBasal = NSDecimalNumber(decimal: tddResult.scheduledBasal)
+            tddStored.weightedAverage = tddResult.weightedAverage.map { NSDecimalNumber(decimal: $0) }
+
+            do {
+                guard self.privateContext.hasChanges else { return }
+                try self.privateContext.save()
+            } catch {
+                debug(.apsManager, "\(DebuggingIdentifiers.failed) Failed to save TDD: \(error.localizedDescription)")
+            }
+        }
+    }
+
+    /// Calculates the number of hours of available pump history data
+    /// - Parameter pumpHistory: Array of pump history events
+    /// - Returns: Number of hours of available data
+    private func calculatePumpDataHours(_ pumpHistory: [PumpHistoryEvent]) -> Double {
+        guard let firstEvent = pumpHistory.last, // we are fetching in a descending order
+              let lastEvent = pumpHistory.first
+        else {
+            return 0
+        }
+
+        let startDate = firstEvent.timestamp
+        var endDate = lastEvent.timestamp
+
+        // If last event in the list is tempBasal, check if it is running longer than current time
+        // If yes, set current date, else ignore
+        if lastEvent.type == .tempBasal, lastEvent.timestamp > Date().addingTimeInterval(-1) {
+            endDate = Date()
+        }
+
+        return Double(endDate.timeIntervalSince(startDate)) / 3600.0
+    }
+
+    /// Calculates total bolus insulin from pump history
+    /// - Parameter bolusEvents: Array of pump history events of type bolus
+    /// - Returns: Total bolus insulin
+    private func calculateBolusInsulin(_ bolusEvents: [PumpHistoryEvent]) -> Decimal {
+        bolusEvents
+            .reduce(Decimal(0)) { totalBolusInsulin, event in
+//                let newTotalBolusInsulin =
+                totalBolusInsulin + (event.amount as Decimal? ?? 0)
+//                debug(
+//                    .apsManager,
+//                    "Bolus \(event.amount ?? 0) U dosed at \(event.timestamp.ISO8601Format()) added. New total bolus = \(newTotalBolusInsulin) U"
+//                )
+//                return newTotalBolusInsulin
+            }
+    }
+
+    /// Calculates temporary basal insulin delivery for a given time period, accounting for interruptions and suspensions
+    /// - Parameters:
+    ///   - tempBasalEvents: Array of temporary basal events
+    ///   - suspendResumePairs: Array of suspend and resume event pairs
+    ///   - roundToSupportedBasalRate: Closure to round rates to pump-supported values
+    /// - Returns: Total insulin delivered via temporary basal rates in units
+    private func calculateTempBasalInsulin(
+        _ tempBasalEvents: [PumpHistoryEvent],
+        suspendResumePairs: [(suspend: PumpHistoryEvent, resume: PumpHistoryEvent)],
+        roundToSupportedBasalRate: @escaping (_ unitsPerHour: Double) -> Double
+    ) -> Decimal {
+        guard !tempBasalEvents.isEmpty else { return 0 }
+
+        // Merge temp basal events and suspend-resume pairs into a single timeline
+        var timeline = [(start: Date, end: Date, type: EventType, rate: Decimal?)]()
+
+        // Add temp basal events to the timeline
+        for event in tempBasalEvents {
+            guard let duration = event.duration, let rate = event.amount else { continue }
+            let end = event.timestamp.addingTimeInterval(TimeInterval(duration * 60))
+            timeline.append((start: event.timestamp, end: end, type: .tempBasal, rate: rate))
+        }
+
+        // Add suspend-resume events to the timeline
+        for suspendResume in suspendResumePairs {
+            timeline.append((
+                start: suspendResume.suspend.timestamp,
+                end: suspendResume.resume.timestamp,
+                type: .pumpSuspend,
+                rate: nil
+            ))
+        }
+
+        // Sort the timeline by start time
+        timeline.sort { $0.start < $1.start }
+
+        // Calculate insulin delivery while accounting for suspensions and premature interruptions
+        var totalInsulin: Decimal = 0
+        let currentDate = Date()
+        var lastEndTime: Date?
+
+        for (index, event) in timeline.enumerated() {
+            if event.type == .tempBasal {
+                let effectiveEnd = min(event.end, currentDate) // Adjust for ongoing temp basals
+                var actualStart = event.start
+                var actualEnd = effectiveEnd
+
+                // Check for interruption by the next event
+                if index < timeline.count - 1 {
+                    let nextEvent = timeline[index + 1]
+                    if nextEvent.start < actualEnd, nextEvent.type != .pumpSuspend {
+                        actualEnd = nextEvent.start
+                    }
+                }
+
+                // Adjust for overlapping suspensions
+                if let lastSuspendEnd = lastEndTime, lastSuspendEnd > actualStart {
+                    actualStart = lastSuspendEnd
+                }
+
+                // Calculate insulin if the duration is valid
+                let durationMinutes = max(0, actualEnd.timeIntervalSince(actualStart) / 60)
+                if durationMinutes > 0, let rate = event.rate {
+                    let durationHours = (Decimal(durationMinutes) / 60).truncated(toPlaces: 5)
+                    let insulin = Decimal(roundToSupportedBasalRate(Double(rate * durationHours)))
+                    if insulin > 0 {
+                        totalInsulin += insulin
+
+//                        debug(
+//                            .apsManager,
+//                            "Temp basal: \(rate) U/hr for \(durationHours) hr (Start: \(actualStart.ISO8601Format()), End: \(actualEnd.ISO8601Format())) = \(insulin) U"
+//                        )
+                    }
+                }
+            } else if event.type == .pumpSuspend {
+                // Update the last suspend end time to adjust future temp basal durations
+                lastEndTime = event.end
+            }
+        }
+
+        return totalInsulin
+    }
+
+    /// Calculates scheduled basal insulin delivery during gaps between temporary basals
+    /// - Parameters:
+    ///   - gaps: Array of time periods where scheduled basal was active
+    ///   - profile: Basal profile entries defining rates throughout the day
+    ///   - roundToSupportedBasalRate: Closure to round rates to pump-supported values
+    /// - Returns: Total insulin delivered via scheduled basal in units
+    private func calculateScheduledBasalInsulin(
+        gaps: [(start: Date, end: Date)],
+        profile: [BasalProfileEntry],
+        roundToSupportedBasalRate: @escaping (_ unitsPerHour: Double) -> Double
+    ) -> Decimal {
+        // Initialize cached formatter for time string conversion
+        let timeFormatter: DateFormatter = {
+            let formatter = DateFormatter()
+            formatter.dateFormat = "HH:mm:ss"
+            return formatter
+        }()
+
+        // Pre-calculate profile switch times for efficient lookup
+        let profileSwitches = profile.map(\.minutes)
+
+        return gaps.reduce(into: Decimal(0)) { totalInsulin, gap in
+            var currentTime = gap.start
+            let now = Date()
+
+            while currentTime < gap.end {
+                // Find applicable basal rate for current time
+                guard let rate = findBasalRate(
+                    for: timeFormatter.string(from: currentTime),
+                    in: profile
+                ) else { break }
+
+                // Determine when rate changes (either profile switch or gap end)
+                let nextSwitchTime = getNextBasalRateSwitch(
+                    after: currentTime,
+                    switches: profileSwitches,
+                    calendar: Calendar.current
+                ) ?? gap.end
+
+                // Ensure endTime does not exceed current time or gap end
+                let endTime = min(min(nextSwitchTime, gap.end), now)
+
+                // Only proceed if we have a valid time interval
+                guard endTime > currentTime else { break }
+
+                let durationHours = (Decimal(endTime.timeIntervalSince(currentTime)) / 3600).truncated(toPlaces: 5)
+                let insulin = Decimal(roundToSupportedBasalRate(Double(rate * durationHours)))
+
+                if insulin > 0 {
+                    totalInsulin += insulin
+
+//                    debug(
+//                        .apsManager,
+//                        "Scheduled Insulin added: \(insulin) U. Duration: \(durationHours) hrs (Start: \(currentTime.ISO8601Format()), End: \(endTime.ISO8601Format()))"
+//                    )
+                }
+
+                currentTime = endTime
+            }
+        }
+    }
+
+    /// Finds gaps between tempBasal events where scheduled basal ran
+    /// - Parameter tempBasalEvents: Array of pump history events of type tempBasal
+    /// - Returns: Array of gaps, where each gap has a start and end time
+    private func findBasalGaps(in tempBasalEvents: [PumpHistoryEvent]) -> [(start: Date, end: Date)] {
+        guard !tempBasalEvents.isEmpty else {
+            let startOfDay = Calendar.current.startOfDay(for: Date())
+            return [(start: startOfDay, end: startOfDay.addingTimeInterval(24 * 60 * 60 - 1))]
+        }
+
+        // Pre-sort events and create array with capacity
+        let sortedEvents = tempBasalEvents.sorted { $0.timestamp < $1.timestamp }
+        var gaps = [(start: Date, end: Date)]()
+        gaps.reserveCapacity(sortedEvents.count + 1)
+
+        // Use first event's date for calendar operations
+        let startOfDay = Calendar.current.startOfDay(for: sortedEvents.first!.timestamp)
+        let endOfDay = startOfDay.addingTimeInterval(24 * 60 * 60 - 1)
+
+        // Process events in a single pass
+        var lastEndTime = sortedEvents.first!.timestamp
+
+        for i in 0 ..< sortedEvents.count {
+            let event = sortedEvents[i]
+            guard let duration = event.duration else { continue }
+
+            // Calculate end time for current event
+            var currentEndTime = event.timestamp.addingTimeInterval(TimeInterval(duration * 60))
+
+            // Check for cancellation by next event
+            if i < sortedEvents.count - 1 {
+                let nextEvent = sortedEvents[i + 1]
+                if nextEvent.timestamp < currentEndTime {
+                    currentEndTime = nextEvent.timestamp
+                }
+            }
+
+            // Record gap if exists
+            if event.timestamp > lastEndTime {
+                gaps.append((start: lastEndTime, end: event.timestamp))
+            }
+
+            lastEndTime = currentEndTime
+        }
+
+        // Add final gap if needed
+        if lastEndTime < endOfDay {
+            gaps.append((start: lastEndTime, end: endOfDay))
+        }
+
+        return gaps
+    }
+
+//    /// Finds gaps between tempBasal events where scheduled basal ran, excluding suspend-resume periods
+//    /// - Parameters:
+//    ///   - tempBasalEvents: Array of pump history events of type tempBasal
+//    ///   - suspendResumePairs: Array of suspend and resume event pairs
+//    /// - Returns: Array of gaps, where each gap has a start and end time
+//    private func findBasalGaps(
+//        in tempBasalEvents: [PumpHistoryEvent],
+//        excluding suspendResumePairs: [(suspend: PumpHistoryEvent, resume: PumpHistoryEvent)]
+//    ) -> [(start: Date, end: Date)] {
+//        guard !tempBasalEvents.isEmpty else {
+//            let startOfDay = Calendar.current.startOfDay(for: Date())
+//            return [(start: startOfDay, end: startOfDay.addingTimeInterval(24 * 60 * 60 - 1))]
+//        }
+//
+//        // Merge temp basal and suspend-resume events into a unified timeline
+//        var timeline = [(start: Date, end: Date, type: EventType)]()
+//
+//        for event in tempBasalEvents {
+//            guard let duration = event.duration else { continue }
+//            let eventEnd = event.timestamp.addingTimeInterval(TimeInterval(duration * 60))
+//            timeline.append((start: event.timestamp, end: eventEnd, type: .tempBasal))
+//        }
+//
+//        for suspendResume in suspendResumePairs {
+//            timeline.append((start: suspendResume.suspend.timestamp, end: suspendResume.resume.timestamp, type: .pumpSuspend))
+//        }
+//
+//        // Sort the timeline by start time
+//        timeline.sort { $0.start < $1.start }
+//
+//        // Process the timeline to calculate gaps
+//        var gaps = [(start: Date, end: Date)]()
+//        var lastEndTime = Calendar.current.startOfDay(for: timeline.first!.start)
+//        let endOfDay = lastEndTime.addingTimeInterval(24 * 60 * 60 - 1)
+//
+//        for interval in timeline {
+//            if interval.type == .pumpSuspend {
+//                // Extend lastEndTime for suspend periods
+//                lastEndTime = max(lastEndTime, interval.end)
+//                continue
+//            }
+//
+//            if interval.start > lastEndTime {
+//                // Add a gap if there is a gap between lastEndTime and interval.start
+//                gaps.append((start: lastEndTime, end: interval.start))
+//            }
+//
+//            // Update lastEndTime to the maximum end time encountered
+//            lastEndTime = max(lastEndTime, interval.end)
+//        }
+//
+//        if lastEndTime < endOfDay {
+//            // Add a final gap if the lastEndTime is before the end of the day
+//            gaps.append((start: lastEndTime, end: endOfDay))
+//        }
+//
+//        return gaps
+//    }
+
+//    /// Calculates scheduled basal insulin delivery during gaps between temporary basals
+//    /// - Parameters:
+//    ///   - gaps: Array of time periods where scheduled basal was active
+//    ///   - profile: Basal profile entries defining rates throughout the day
+//    ///   - roundToSupportedBasalRate: Closure to round rates to pump-supported values
+//    /// - Returns: Total insulin delivered via scheduled basal in units
+//    private func calculateScheduledBasalInsulin(
+//        gaps: [(start: Date, end: Date)],
+//        profile: [BasalProfileEntry],
+//        roundToSupportedBasalRate: @escaping (_ unitsPerHour: Double) -> Double
+//    ) -> Decimal {
+//        // Initialize cached formatter for time string conversion
+//        let timeFormatter: DateFormatter = {
+//            let formatter = DateFormatter()
+//            formatter.dateFormat = "HH:mm:ss"
+//            return formatter
+//        }()
+//
+//        // Pre-calculate profile switch times for efficient lookup
+//        let profileSwitches = profile.map(\.minutes)
+//
+//        return gaps.reduce(into: Decimal(0)) { totalInsulin, gap in
+//            var currentTime = gap.start
+//
+//            while currentTime < gap.end {
+//                // Find applicable basal rate for the current time
+//                guard let rate = findBasalRate(
+//                    for: timeFormatter.string(from: currentTime),
+//                    in: profile
+//                ) else { break }
+//
+//                // Determine when the rate changes (profile switch or gap end)
+//                let nextSwitchTime = getNextBasalRateSwitch(
+//                    after: currentTime,
+//                    switches: profileSwitches,
+//                    calendar: Calendar.current
+//                ) ?? gap.end
+//                let endTime = min(nextSwitchTime, gap.end)
+//                let durationHours = Decimal(endTime.timeIntervalSince(currentTime)) / 3600
+//
+//                let insulin = Decimal(roundToSupportedBasalRate(Double(rate * durationHours)))
+//                totalInsulin += insulin
+//
+//                debug(
+//                    .apsManager,
+//                    "Scheduled Insulin added: \(insulin) U. Duration: \(durationHours) hrs (\(currentTime)-\(endTime))"
+//                )
+//
+//                currentTime = endTime
+//            }
+//        }
+//    }
+
+    /// Finds the next basal rate switch time after a given time
+    /// - Parameters:
+    ///   - time: Reference time to find next switch after
+    ///   - switches: Pre-calculated array of minutes when profile rates change
+    ///   - calendar: Calendar instance for date calculations
+    /// - Returns: Date of next basal rate switch, or nil if none found
+    private func getNextBasalRateSwitch(
+        after time: Date,
+        switches: [Int],
+        calendar: Calendar
+    ) -> Date? {
+        let timeMinutes = calendar.component(.hour, from: time) * 60 + calendar.component(.minute, from: time)
+
+        // Find first switch time after current time
+        guard let nextSwitch = switches.first(where: { $0 > timeMinutes }) else {
+            return nil
+        }
+
+        // Convert switch time to absolute date
+        return calendar.startOfDay(for: time).addingTimeInterval(TimeInterval(nextSwitch * 60))
+    }
+
+    /// Finds the basal rate for a specific time using binary search
+    /// - Parameters:
+    ///   - timeString: Time in format "HH:mm:ss"
+    ///   - profile: Array of basal profile entries sorted by time
+    /// - Returns: Basal rate in units per hour, or nil if not found
+    private func findBasalRate(for timeString: String, in profile: [BasalProfileEntry]) -> Decimal? {
+        // Parse time string in "HH:mm:ss" format into hours and minutes components
+        let timeComponents = timeString.split(separator: ":")
+        guard timeComponents.count == 3,
+              let hours = Int(timeComponents[0]),
+              let minutes = Int(timeComponents[1])
+        else { return nil }
+
+        // Convert time to total minutes since midnight for easier comparison
+        let totalMinutes = hours * 60 + minutes
+
+        // Special case: If profile has only one entry, it applies for full 24 hours
+        // Return its rate immediately without searching
+        if profile.count == 1 {
+            return profile[0].rate
+        }
+
+        // Use binary search to efficiently find the applicable basal rate
+        // Profile entries are sorted by minutes, so we can divide and conquer
+        var left = 0
+        var right = profile.count - 1
+
+        while left <= right {
+            let mid = (left + right) / 2
+            let entry = profile[mid]
+            // Get end time for current entry - either next entry's start time or end of day (1440 mins)
+            let nextMinutes = mid + 1 < profile.count ? profile[mid + 1].minutes : 1440
+
+            // Check if target time falls within current entry's time range
+            if totalMinutes >= entry.minutes, totalMinutes < nextMinutes {
+                return entry.rate
+            }
+
+            // Adjust search range based on comparison
+            if totalMinutes < entry.minutes {
+                right = mid - 1 // Search in left half if target time is earlier
+            } else {
+                left = mid + 1 // Search in right half if target time is later
+            }
+        }
+
+        // No applicable rate found for the given time
+        return nil
+    }
+
+    /// Calculates a weighted average of Total Daily Dose (TDD) based on recent and historical data
+    ///
+    /// The weighted average is calculated using two time periods:
+    /// - Recent: Last 2 hours of TDD data
+    /// - Historical: Last 10 days of TDD data
+    ///
+    /// The formula used is:
+    /// ```
+    /// weightedTDD = (weightPercentage × recent_average) + ((1 - weightPercentage) × historical_average)
+    /// ```
+    /// where weightPercentage defaults to 0.65 if not set in preferences
+    ///
+    /// - Returns: A weighted average of TDD as Decimal, or nil if insufficient data
+    /// - Note: The weight percentage can be configured in preferences. Default is 0.65 (65% recent, 35% historical)
+    private func calculateWeightedAverage() async throws -> Decimal? {
+        // Fetch data from Core Data
+        let tenDaysAgo = Date().addingTimeInterval(-10.days.timeInterval)
+        let twoHoursAgo = Date().addingTimeInterval(-2.hours.timeInterval)
+
+        let predicate = NSPredicate(format: "date >= %@", tenDaysAgo as NSDate)
+
+        let 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 }
+
+            // 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
+
+            // Calculate 10-day average
+            let totalTDD = results.compactMap { $0.total?.decimalValue }.reduce(0, +)
+            let totalCount = max(Decimal(results.count), 1)
+            let averageTDDLastTenDays = totalTDD / totalCount
+
+            // Get weight percentage from preferences (default 0.65 if not set)
+            let userPreferences = self.storage.retrieve(OpenAPS.Settings.preferences, as: Preferences.self)
+            let weightPercentage = userPreferences?.weightPercentage ?? Decimal(0.65) // why is this 1 as default in oref2??
+
+            // Calculate weighted average using the formula:
+            // weightedTDD = (weightPercentage × recent_average) + ((1 - weightPercentage) × historical_average)
+            let weightedTDD = weightPercentage * averageTDDLastTwoHours +
+                (1 - weightPercentage) * averageTDDLastTenDays
+
+            return weightedTDD.truncated(toPlaces: 3)
+        }
+    }
+}
+
+/// Extension for rounding Decimal numbers
+extension Decimal {
+    /// Rounds a decimal to specified number of places
+    /// - Parameter places: Number of decimal places
+    /// - Returns: Rounded decimal
+    func rounded(toPlaces places: Int) -> Decimal {
+        var value = self
+        var result = Decimal()
+        NSDecimalRound(&result, &value, places, .plain)
+        return result
+    }
+
+    /// Truncates the `Decimal` to the specified number of decimal places without rounding.
+    ///
+    /// - Parameter places: The number of decimal places to retain.
+    /// - Returns: A `Decimal` truncated to the specified precision.
+    func truncated(toPlaces places: Int) -> Decimal {
+        var original = self
+        var result = Decimal()
+        NSDecimalRound(&result, &original, places, .down)
+        return result
+    }
+}

+ 85 - 53
Trio/Sources/APS/Storage/TempTargetsStorage.swift

@@ -8,18 +8,18 @@ protocol TempTargetsObserver {
 }
 
 protocol TempTargetsStorage {
-    func storeTempTarget(tempTarget: TempTarget) async
+    func storeTempTarget(tempTarget: TempTarget) async throws
     func saveTempTargetsToStorage(_ targets: [TempTarget])
-    func fetchForTempTargetPresets() async -> [NSManagedObjectID]
-    func fetchScheduledTempTargets() async -> [NSManagedObjectID]
-    func fetchScheduledTempTarget(for targetDate: Date) async -> [NSManagedObjectID]
+    func fetchForTempTargetPresets() async throws -> [NSManagedObjectID]
+    func fetchScheduledTempTargets() async throws -> [NSManagedObjectID]
+    func fetchScheduledTempTarget(for targetDate: Date) async throws -> [NSManagedObjectID]
     func copyRunningTempTarget(_ tempTarget: TempTargetStored) async -> NSManagedObjectID
     func deleteTempTargetPreset(_ objectID: NSManagedObjectID) async
-    func loadLatestTempTargetConfigurations(fetchLimit: Int) async -> [NSManagedObjectID]
+    func loadLatestTempTargetConfigurations(fetchLimit: Int) async throws -> [NSManagedObjectID]
     func syncDate() -> Date
     func recent() -> [TempTarget]
-    func getTempTargetsNotYetUploadedToNightscout() async -> [NightscoutTreatment]
-    func getTempTargetRunsNotYetUploadedToNightscout() async -> [NightscoutTreatment]
+    func getTempTargetsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment]
+    func getTempTargetRunsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment]
     func presets() -> [TempTarget]
     func current() -> TempTarget?
     func existsTempTarget(with date: Date) async -> Bool
@@ -31,93 +31,103 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var settingsManager: SettingsManager!
 
-    private let backgroundContext = CoreDataStack.shared.newTaskContext()
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
-    init(resolver: Resolver) {
+    private let context: NSManagedObjectContext
+
+    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
+        self.context = context ?? CoreDataStack.shared.newTaskContext()
         injectServices(resolver)
     }
 
-    func loadLatestTempTargetConfigurations(fetchLimit: Int) async -> [NSManagedObjectID] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func loadLatestTempTargetConfigurations(fetchLimit: Int) async throws -> [NSManagedObjectID] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.lastActiveTempTarget,
             key: "orderPosition",
             ascending: true,
             fetchLimit: fetchLimit
         )
 
-        guard let fetchedResults = results as? [TempTargetStored] else { return [] }
+        return try await context.perform {
+            guard let fetchedResults = results as? [TempTargetStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
-        return await backgroundContext.perform {
             return fetchedResults.map(\.objectID)
         }
     }
 
     /// Returns the NSManagedObjectID of the Temp Target Presets
-    func fetchForTempTargetPresets() async -> [NSManagedObjectID] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func fetchForTempTargetPresets() async throws -> [NSManagedObjectID] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.allTempTargetPresets,
             key: "orderPosition",
             ascending: true
         )
 
-        guard let fetchedResults = results as? [TempTargetStored] else { return [] }
+        return try await context.perform {
+            guard let fetchedResults = results as? [TempTargetStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
-        return await backgroundContext.perform {
             return fetchedResults.map(\.objectID)
         }
     }
 
-    func fetchScheduledTempTargets() async -> [NSManagedObjectID] {
+    func fetchScheduledTempTargets() async throws -> [NSManagedObjectID] {
         let scheduledTempTargets = NSPredicate(format: "date > %@", Date() as NSDate)
 
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: scheduledTempTargets,
             key: "date",
             ascending: false
         )
 
-        guard let fetchedResults = results as? [TempTargetStored] else { return [] }
+        return try await context.perform {
+            guard let fetchedResults = results as? [TempTargetStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
-        return await backgroundContext.perform {
             return fetchedResults.map(\.objectID)
         }
     }
 
-    func fetchScheduledTempTarget(for targetDate: Date) async -> [NSManagedObjectID] {
+    func fetchScheduledTempTarget(for targetDate: Date) async throws -> [NSManagedObjectID] {
         let predicate = NSPredicate(format: "date == %@", targetDate as NSDate)
 
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: predicate,
             key: "date",
             ascending: false,
             fetchLimit: 1
         )
 
-        guard let fetchedResults = results as? [TempTargetStored] else { return [] }
+        return try await context.perform {
+            guard let fetchedResults = results as? [TempTargetStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
-        return await backgroundContext.perform {
-            fetchedResults.map(\.objectID)
+            return fetchedResults.map(\.objectID)
         }
     }
 
-    func storeTempTarget(tempTarget: TempTarget) async {
+    func storeTempTarget(tempTarget: TempTarget) async throws {
         var presetCount = -1
         if tempTarget.isPreset == true {
-            let presets = await fetchForTempTargetPresets()
+            let presets = try await fetchForTempTargetPresets()
             presetCount = presets.count
         }
 
-        await backgroundContext.perform {
-            let newTempTarget = TempTargetStored(context: self.backgroundContext)
+        try await context.perform {
+            let newTempTarget = TempTargetStored(context: self.context)
             newTempTarget.date = tempTarget.createdAt
             newTempTarget.id = UUID()
             newTempTarget.enabled = tempTarget.enabled ?? false
@@ -141,12 +151,11 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
             }
 
             do {
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard self.context.hasChanges else { return }
+                try self.context.save()
             } catch let error as NSError {
-                debugPrint(
-                    "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save Temp Target to Core Data with error: \(error.userInfo)"
-                )
+                debug(.default, "\(DebuggingIdentifiers.failed) Failed to save new temp target with error: \(error.userInfo)")
+                throw error
             }
         }
     }
@@ -173,13 +182,13 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
 
     func existsTempTarget(with date: Date) async -> Bool {
-        await backgroundContext.perform {
+        await context.perform {
             // Fetch all Temp Targets with the given date
             let fetchRequest: NSFetchRequest<TempTargetStored> = TempTargetStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "date == %@", date as NSDate)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try self.context.fetch(fetchRequest)
                 return !results.isEmpty
             } catch let error as NSError {
                 debugPrint("\(DebuggingIdentifiers.failed) Failed to check for existing Temp Target: \(error)")
@@ -216,8 +225,27 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
         return newTempTarget.objectID
     }
 
-    @MainActor func deleteTempTargetPreset(_ objectID: NSManagedObjectID) async {
-        await CoreDataStack.shared.deleteObject(identifiedBy: objectID)
+    func deleteTempTargetPreset(_ objectID: NSManagedObjectID) async {
+        let taskContext = context != CoreDataStack.shared.newTaskContext()
+            ? context
+            : CoreDataStack.shared.newTaskContext()
+
+        await taskContext.perform {
+            do {
+                let result = try taskContext.existingObject(with: objectID) as? TempTargetStored
+                guard let tempTarget = result else {
+                    debug(.default, "\(DebuggingIdentifiers.failed) Temp Target for batch delete not found.")
+                    return
+                }
+
+                taskContext.delete(tempTarget)
+
+                guard taskContext.hasChanges else { return }
+                try taskContext.save()
+            } catch {
+                debug(.default, "\(DebuggingIdentifiers.failed) Failed to delete Temp Target: \(error)")
+            }
+        }
     }
 
     func syncDate() -> Date {
@@ -242,17 +270,19 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
         return last
     }
 
-    func getTempTargetsNotYetUploadedToNightscout() async -> [NightscoutTreatment] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func getTempTargetsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.lastActiveAdjustmentNotYetUploadedToNightscout,
             key: "date",
             ascending: false
         )
 
-        return await backgroundContext.perform {
-            guard let fetchedTempTargets = results as? [TempTargetStored] else { return [] }
+        return try await context.perform {
+            guard let fetchedTempTargets = results as? [TempTargetStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
             return fetchedTempTargets.map { tempTarget in
                 NightscoutTreatment(
@@ -277,10 +307,10 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
         }
     }
 
-    func getTempTargetRunsNotYetUploadedToNightscout() async -> [NightscoutTreatment] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+    func getTempTargetRunsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetRunStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate(
                 format: "startDate >= %@ AND isUploadedToNS == %@",
                 Date.oneDayAgo as NSDate,
@@ -290,8 +320,10 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
             ascending: false
         )
 
-        return await backgroundContext.perform {
-            guard let fetchedTempTargetRuns = results as? [TempTargetRunStored] else { return [] }
+        return try await context.perform {
+            guard let fetchedTempTargetRuns = results as? [TempTargetRunStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
             return fetchedTempTargetRuns.map { tempTargetRun in
                 var durationInMinutes = (tempTargetRun.endDate?.timeIntervalSince(tempTargetRun.startDate ?? Date()) ?? 1) / 60

+ 20 - 6
Trio/Sources/Application/AppDelegate.swift

@@ -4,11 +4,11 @@ import UserNotifications
 
 class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject, UNUserNotificationCenterDelegate {
     func application(
-        _ application: UIApplication,
+        _: UIApplication,
         didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?
     ) -> Bool {
-        application.registerForRemoteNotifications()
-        return true
+        // application.registerForRemoteNotifications()
+        true
     }
 
     func application(
@@ -23,8 +23,15 @@ class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject, UNUserNoti
             let pushMessage = try JSONDecoder().decode(PushMessage.self, from: jsonData)
 
             Task {
-                await TrioRemoteControl.shared.handleRemoteNotification(pushMessage: pushMessage)
-                completionHandler(.newData)
+                do {
+                    try await TrioRemoteControl.shared.handleRemoteNotification(pushMessage: pushMessage)
+                    completionHandler(.newData)
+                } catch {
+                    debug(
+                        .default,
+                        "\(DebuggingIdentifiers.failed) failed to handle remote notification with error: \(error.localizedDescription)"
+                    )
+                }
             }
         } catch {
             debug(.remoteControl, "Error decoding push message: \(error.localizedDescription)")
@@ -40,7 +47,14 @@ class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject, UNUserNoti
         let token = tokenParts.joined()
 
         Task {
-            await TrioRemoteControl.shared.handleAPNSChanges(deviceToken: token)
+            do {
+                try await TrioRemoteControl.shared.handleAPNSChanges(deviceToken: token)
+            } catch {
+                debug(
+                    .remoteControl,
+                    "\(DebuggingIdentifiers.failed) failed to register for remote notifications: \(error.localizedDescription)"
+                )
+            }
         }
     }
 

+ 118 - 39
Trio/Sources/Application/TrioApp.swift

@@ -5,6 +5,11 @@ import Foundation
 import SwiftUI
 import Swinject
 
+extension Notification.Name {
+    static let initializationCompleted = Notification.Name("initializationCompleted")
+    static let initializationError = Notification.Name("initializationError")
+}
+
 @main struct TrioApp: App {
     @Environment(\.scenePhase) var scenePhase
 
@@ -13,9 +18,21 @@ import Swinject
     // Read the color scheme preference from UserDefaults; defaults to system default setting
     @AppStorage("colorSchemePreference") private var colorSchemePreference: ColorSchemeOption = .systemDefault
 
-    let coreDataStack: CoreDataStack
+    let coreDataStack = CoreDataStack.shared
+    class InitState {
+        var complete = false
+        var error = false
+    }
+
+    // We use both InitState and @State variables to track coreDataStack
+    // initialization. We need both to handle the cases when the coreDataStack
+    // finishes before the UI and when it finishes after. SwiftUI doesn't have
+    // clean mechanisms for handling background thread updates, thus this solution.
+    let initState = InitState()
 
     @State private var appState = AppState()
+    @State private var showLoadingView = true
+    @State private var showLoadingError = false
 
     // Dependencies Assembler
     // contain all dependencies Assemblies
@@ -58,49 +75,103 @@ import Swinject
         _ = resolver.resolve(PluginManager.self)!
         _ = resolver.resolve(AlertPermissionsChecker.self)!
         if #available(iOS 16.2, *) {
-            _ = resolver.resolve(LiveActivityBridge.self)!
+            _ = resolver.resolve(LiveActivityManager.self)!
         }
     }
 
     init() {
+        let submodulesInfo = BuildDetails.default.submodules.map { key, value in
+            "\(key): \(value.branch) \(value.commitSHA)"
+        }.joined(separator: ", ")
+
         debug(
             .default,
-            "Trio Started: v\(Bundle.main.releaseVersionNumber ?? "")(\(Bundle.main.buildVersionNumber ?? "")) [buildDate: \(String(describing: BuildDetails.default.buildDate()))] [buildExpires: \(String(describing: BuildDetails.default.calculateExpirationDate()))]"
+            "Trio Started: v\(Bundle.main.releaseVersionNumber ?? "")(\(Bundle.main.buildVersionNumber ?? "")) [buildDate: \(String(describing: BuildDetails.default.buildDate()))] [buildExpires: \(String(describing: BuildDetails.default.calculateExpirationDate()))] [submodules: \(submodulesInfo)]"
         )
 
-        // Setup up the Core Data Stack
-        coreDataStack = CoreDataStack.shared
+        // Fix bug in iOS 18 related to the translucent tab bar
+        configureTabBarAppearance()
 
-        do {
-            // Explicitly initialize Core Data Stacak
-            try coreDataStack.initializeStack()
+        deferredInitialization()
+    }
+
+    /// Handles the deferred initialization of core components.
+    ///
+    /// Performs CoreDataStack initialization asynchronously and notifies the UI
+    /// of completion or errors via notifications.
+    private func deferredInitialization() {
+        Task {
+            do {
+                try await coreDataStack.initializeStack()
 
-            // Load services
-            loadServices()
+                await MainActor.run {
+                    // Only load services after successful Core Data initialization
+                    loadServices()
 
-            // Fix bug in iOS 18 related to the translucent tab bar
-            configureTabBarAppearance()
+                    // Clear the persistentHistory and the NSManagedObjects that are older than 90 days every time the app starts
+                    cleanupOldData()
 
-            // Clear the persistentHistory and the NSManagedObjects that are older than 90 days every time the app starts
-            cleanupOldData()
-        } catch {
-            debug(
-                .coreData,
-                "Failed to initialize Core Data Stack: \(error.localizedDescription)"
-            )
-            // Handle initialization failure
-            fatalError("Core Data Stack initialization failed: \(error.localizedDescription)")
+                    self.initState.complete = true
+                    Foundation.NotificationCenter.default.post(name: .initializationCompleted, object: nil)
+                    UIApplication.shared.registerForRemoteNotifications()
+                }
+            } catch {
+                debug(
+                    .coreData,
+                    "\(DebuggingIdentifiers.failed) Failed to initialize Core Data Stack: \(error.localizedDescription)"
+                )
+
+                await MainActor.run {
+                    self.initState.error = true
+                    Foundation.NotificationCenter.default.post(name: .initializationError, object: nil)
+                }
+            }
         }
     }
 
+    /// Attempts to initialize the CoreDataStack again after a previous failure.
+    ///
+    /// Resets error states and triggers the initialization process from the beginning. Called in response
+    /// to a UI "retry" button press from the Main.LoadingView
+    private func retryCoreDataInitialization() {
+        showLoadingError = false
+        initState.error = false
+        deferredInitialization()
+    }
+
     var body: some Scene {
         WindowGroup {
-            Main.RootView(resolver: resolver)
-                .preferredColorScheme(colorScheme(for: colorSchemePreference) ?? nil)
-                .environment(\.managedObjectContext, coreDataStack.persistentContainer.viewContext)
-                .environment(appState)
-                .environmentObject(Icons())
-                .onOpenURL(perform: handleURL)
+            if self.showLoadingView {
+                Main.LoadingView(showError: $showLoadingError, retry: retryCoreDataInitialization)
+                    .onAppear {
+                        if self.initState.complete {
+                            Task { @MainActor in
+                                try? await Task.sleep(for: .seconds(1.8))
+                                self.showLoadingView = false
+                            }
+                        }
+                        if self.initState.error {
+                            self.showLoadingError = true
+                        }
+                    }
+                    .onReceive(Foundation.NotificationCenter.default.publisher(for: .initializationCompleted)) { _ in
+                        Task { @MainActor in
+                            try? await Task.sleep(for: .seconds(1.8))
+                            self.showLoadingView = false
+                        }
+                    }
+                    .onReceive(Foundation.NotificationCenter.default.publisher(for: .initializationError)) { _ in
+                        self.showLoadingError = true
+                    }
+
+            } else {
+                Main.RootView(resolver: resolver)
+                    .preferredColorScheme(colorScheme(for: colorSchemePreference) ?? nil)
+                    .environment(\.managedObjectContext, coreDataStack.persistentContainer.viewContext)
+                    .environment(appState)
+                    .environmentObject(Icons())
+                    .onOpenURL(perform: handleURL)
+            }
         }
         .onChange(of: scenePhase) { _, newScenePhase in
             debug(.default, "APPLICATION PHASE: \(newScenePhase)")
@@ -109,10 +180,17 @@ import Swinject
             if newScenePhase == .background {
                 coreDataStack.save()
             }
-        }
-        .backgroundTask(.appRefresh("com.trio.cleanup")) {
-            await scheduleDatabaseCleaning()
-            await cleanupOldData()
+
+            if newScenePhase == .active {
+                if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+                   let rootVC = windowScene.windows.first(where: { $0.isKeyWindow })?.rootViewController
+                {
+                    AppVersionChecker.shared.checkAndNotifyVersionStatus(in: rootVC)
+                }
+                if initState.complete {
+                    performCleanupIfNecessary()
+                }
+            }
         }
     }
 
@@ -137,14 +215,12 @@ import Swinject
         }
     }
 
-    func scheduleDatabaseCleaning() {
-        let request = BGAppRefreshTaskRequest(identifier: "com.trio.cleanup")
-        request.earliestBeginDate = .now.addingTimeInterval(7 * 24 * 60 * 60) // 7 days
-        do {
-            try BGTaskScheduler.shared.submit(request)
-            debugPrint("Task scheduled successfully")
-        } catch {
-            debugPrint("Failed to schedule tasks")
+    private func performCleanupIfNecessary() {
+        if let lastCleanupDate = UserDefaults.standard.object(forKey: "lastCleanupDate") as? Date {
+            let sevenDaysAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
+            if lastCleanupDate < sevenDaysAgo {
+                cleanupOldData()
+            }
         }
     }
 
@@ -155,6 +231,9 @@ import Swinject
 
             await cleanupTokens
             try await purgeData
+
+            // Update the last cleanup date
+            UserDefaults.standard.set(Date(), forKey: "lastCleanupDate")
         }
     }
 

+ 2 - 2
Trio/Sources/Assemblies/ServiceAssembly.swift

@@ -24,8 +24,8 @@ final class ServiceAssembly: Assembly {
         container.register(ContactImageManager.self) { r in BaseContactImageManager(resolver: r) }
         container.register(AlertPermissionsChecker.self) { r in AlertPermissionsChecker(resolver: r) }
         if #available(iOS 16.2, *) {
-            container.register(LiveActivityBridge.self) { r in
-                LiveActivityBridge(resolver: r)
+            container.register(LiveActivityManager.self) { r in
+                LiveActivityManager(resolver: r)
             }
         }
     }

+ 1 - 0
Trio/Sources/Assemblies/StorageAssembly.swift

@@ -10,6 +10,7 @@ final class StorageAssembly: Assembly {
         container.register(PumpHistoryStorage.self) { r in BasePumpHistoryStorage(resolver: r) }
         container.register(OverrideStorage.self) { r in BaseOverrideStorage(resolver: r) }
         container.register(DeterminationStorage.self) { r in BaseDeterminationStorage(resolver: r) }
+        container.register(TDDStorage.self) { r in BaseTDDStorage(resolver: r) }
         container.register(GlucoseStorage.self) { r in BaseGlucoseStorage(resolver: r) }
         container.register(TempTargetsStorage.self) { r in BaseTempTargetsStorage(resolver: r) }
         container.register(CarbsStorage.self) { r in BaseCarbsStorage(resolver: r) }

+ 1 - 1
Trio/Sources/Config/Config.swift

@@ -4,6 +4,6 @@ import SwiftDate
 enum Config {
     static let treatWarningsAsErrors = true
     static let withSignPosts = false
-    static let loopInterval = 4.minutes.timeInterval
+    static let loopInterval = 3.minutes.timeInterval
     static let eхpirationInterval = 10.minutes.timeInterval
 }

+ 29 - 0
Trio/Sources/Helpers/BackgroundTask+Helper.swift

@@ -0,0 +1,29 @@
+import UIKit
+
+/// Ends a background task safely and ensures it is not called multiple times.
+///
+/// - Parameter taskID: The background task identifier to be ended.
+func endBackgroundTaskSafely(_ taskID: inout UIBackgroundTaskIdentifier, taskName: String = "Unnamed Task") {
+    if taskID != .invalid {
+        UIApplication.shared.endBackgroundTask(taskID)
+        debug(.default, "Background task '\(taskName)' ended successfully.")
+        taskID = .invalid
+    } else {
+        debug(.default, "Background task '\(taskName)' was already invalid or ended.")
+    }
+}
+
+/// Starts a background task and handles its expiration safely.
+///
+/// - Parameter name: The background task name.
+func startBackgroundTask(withName name: String) -> UIBackgroundTaskIdentifier {
+    var taskID = UIBackgroundTaskIdentifier.invalid
+
+    taskID = UIApplication.shared.beginBackgroundTask(withName: name) {
+        Task { @MainActor in
+            endBackgroundTaskSafely(&taskID, taskName: name)
+        }
+    }
+
+    return taskID
+}

+ 20 - 11
Trio/Sources/Helpers/BuildDetails.swift

@@ -1,9 +1,3 @@
-//
-//  BuildDetails.swift
-//  Trio
-//
-//  Created by Jonas Björkert on 2024-05-09.
-//
 import Foundation
 
 class BuildDetails {
@@ -14,7 +8,7 @@ class BuildDetails {
     init() {
         guard let url = Bundle.main.url(forResource: "BuildDetails", withExtension: "plist"),
               let data = try? Data(contentsOf: url),
-              let parsed = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any]
+              let parsed = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any]
         else {
             dict = [:]
             return
@@ -27,11 +21,26 @@ class BuildDetails {
     }
 
     var branchAndSha: String {
-        let branch = dict["com-trio-branch"] as? String ?? "Unknown"
-        let sha = dict["com-trio-commit-sha"] as? String ?? "Unknown"
+        let branch = dict["com-trio-branch"] as? String ?? String(localized: "Unknown")
+        let sha = dict["com-trio-commit-sha"] as? String ?? String(localized: "Unknown")
         return "\(branch) \(sha)"
     }
 
+    /// Returns a dictionary of submodule details.
+    /// The keys are the submodule names, and the values are tuples (branch, commitSHA).
+    var submodules: [String: (branch: String, commitSHA: String)] {
+        guard let subs = dict["com-trio-submodules"] as? [String: [String: Any]] else {
+            return [:]
+        }
+        var result = [String: (branch: String, commitSHA: String)]()
+        for (name, info) in subs {
+            let branch = info["branch"] as? String ?? String(localized: "Unknown")
+            let commitSHA = info["commit_sha"] as? String ?? String(localized: "Unknown")
+            result[name] = (branch: branch, commitSHA: commitSHA)
+        }
+        return result
+    }
+
     // Determine if the build is from TestFlight
     func isTestFlightBuild() -> Bool {
         #if targetEnvironment(simulator)
@@ -75,9 +84,9 @@ class BuildDetails {
     // Expiration header based on build type
     var expirationHeaderString: String {
         if isTestFlightBuild() {
-            return "Beta (TestFlight) Expires"
+            return String(localized: "Beta (TestFlight) Expires")
         } else {
-            return "App Expires"
+            return String(localized: "App Expires")
         }
     }
 }

+ 19 - 0
Trio/Sources/Helpers/Calendar+GlucoseStatsChart.swift

@@ -0,0 +1,19 @@
+import Foundation
+
+extension Calendar {
+    /// Converts an hour (0-23) to a Date object representing that hour on the current day.
+    /// This is used to properly position marks on the chart's time axis.
+    ///
+    /// - Parameter hour: Integer representing the hour of day (0-23)
+    /// - Returns: Date object set to the specified hour on the current day
+    ///
+    /// Example:
+    /// ```
+    /// calendar.dateForChartHour(14) // Returns today's date at 2:00 PM
+    /// calendar.dateForChartHour(0)  // Returns today's date at 12:00 AM
+    /// ```
+    func dateForChartHour(_ hour: Int) -> Date {
+        let today = startOfDay(for: Date())
+        return date(byAdding: .hour, value: hour, to: today) ?? today
+    }
+}

+ 1 - 1
Trio/Sources/Helpers/CustomProgressView.swift

@@ -32,7 +32,7 @@ struct CustomProgressView: View {
                         .frame(width: 80, height: 3)
                         .offset(x: self.animate ? 180 : -180, y: 0)
                         .animation(
-                            Animation.linear(duration: 2)
+                            Animation.linear(duration: 1)
                                 .repeatForever(autoreverses: false), value: UUID()
                         )
                 )

+ 14 - 0
Trio/Sources/Helpers/Formatters.swift

@@ -42,6 +42,12 @@ extension Formatter {
         return dateFormatter
     }()
 
+    static let dayFormatter: DateFormatter = {
+        let formatter = DateFormatter()
+        formatter.dateFormat = "d"
+        return formatter
+    }()
+
     static let decimalFormatterWithOneFractionDigit: NumberFormatter = {
         let formatter = NumberFormatter()
         formatter.numberStyle = .decimal
@@ -80,6 +86,14 @@ extension Formatter {
         formatter.decimalSeparator = "."
         return formatter
     }()
+
+    static let timaAgoFormatter: NumberFormatter = {
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .decimal
+        formatter.maximumFractionDigits = 0
+        formatter.negativePrefix = ""
+        return formatter
+    }()
 }
 
 extension JSONDecoder.DateDecodingStrategy {

+ 4 - 4
Trio/Sources/Helpers/HKUnit.swift

@@ -44,13 +44,13 @@ extension HKUnit {
 
     var localizedShortUnitString: String {
         if self == HKUnit.millimolesPerLiter {
-            return NSLocalizedString("mmol/L", comment: "The short unit display string for millimoles of glucose per liter")
+            return String(localized: "mmol/L", comment: "The short unit display string for millimoles of glucose per liter")
         } else if self == .milligramsPerDeciliter {
-            return NSLocalizedString("mg/dL", comment: "The short unit display string for milligrams of glucose per decilter")
+            return String(localized: "mg/dL", comment: "The short unit display string for milligrams of glucose per decilter")
         } else if self == .internationalUnit() {
-            return NSLocalizedString("U", comment: "The short unit display string for international units of insulin")
+            return String(localized: "U", comment: "The short unit display string for international units of insulin")
         } else if self == .gram() {
-            return NSLocalizedString("g", comment: "The short unit display string for grams")
+            return String(localized: "g", comment: "The short unit display string for grams")
         } else {
             return String(describing: self)
         }

+ 1 - 1
Trio/Sources/Helpers/MainChartHelper.swift

@@ -50,7 +50,7 @@ enum MainChartHelper {
     }
 
     static func bolusOffset(units: GlucoseUnits) -> Decimal {
-        units == .mgdL ? 30 : 1.66
+        units == .mgdL ? 20 : (20 / 18)
     }
 
     static func calculateDuration(

+ 21 - 0
Trio/Sources/Helpers/TherapySettingsUtil.swift

@@ -0,0 +1,21 @@
+import Foundation
+
+enum TherapySettingsUtil {
+    /// Parses a time string of therapy setting entry into a `Date` object using either "HH:mm:ss" or "HH:mm" formats.
+    /// This function ensures compatibility with time strings that may include or exclude seconds.
+    ///
+    /// - Parameter timeString: A string representing the time in "HH:mm:ss" or "HH:mm" format.
+    /// - Returns: A `Date` object set to today’s date with the extracted time, or `nil` if parsing fails.
+    static func parseTime(_ timeString: String) -> Date? {
+        let formats = ["HH:mm:ss", "HH:mm"]
+        for format in formats {
+            let formatter = DateFormatter()
+            formatter.dateFormat = format
+            formatter.timeZone = TimeZone.current
+            if let date = formatter.date(from: timeString) {
+                return date
+            }
+        }
+        return nil
+    }
+}

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


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


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


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


+ 0 - 0
Trio/Sources/Localizations/Main/de.lproj/Localizable.strings


Некоторые файлы не были показаны из-за большого количества измененных файлов