Преглед изворни кода

Merge branch 'dev' into feat/dev-medtrum

Deniz Cengiz пре 1 месец
родитељ
комит
d7e4805552
28 измењених фајлова са 483 додато и 228 уклоњено
  1. 3 0
      .github/FUNDING.yml
  2. 1 1
      .github/workflows/add_identifiers.yml
  3. 1 1
      .github/workflows/auto_version_dev.yml
  4. 4 4
      .github/workflows/build_trio.yml
  5. 2 2
      .github/workflows/create_certs.yml
  6. 2 2
      .github/workflows/stale_issues.yml
  7. 3 3
      .github/workflows/unit_tests.yml
  8. 1 1
      .github/workflows/validate_secrets.yml
  9. 1 1
      Config.xcconfig
  10. 2 2
      Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents
  11. 66 0
      Trio Watch App Extension/Helper/WatchNotificationHandler.swift
  12. 5 0
      Trio Watch App Extension/TrioWatchApp.swift
  13. 10 0
      Trio.xcodeproj/project.pbxproj
  14. 38 33
      Trio/Sources/APS/FetchGlucoseManager.swift
  15. 1 1
      Trio/Sources/APS/OpenAPS/OpenAPS.swift
  16. 6 3
      Trio/Sources/APS/Storage/PumpHistoryStorage.swift
  17. 19 80
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  18. 64 0
      Trio/Sources/Models/NotificationIdentifiers.swift
  19. 3 0
      Trio/Sources/Models/WatchMessageKeys.swift
  20. 31 2
      Trio/Sources/Modules/Snooze/SnoozeStateModel.swift
  21. 26 30
      Trio/Sources/Modules/Snooze/View/SnoozeRootView.swift
  22. 1 1
      Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift
  23. 2 2
      Trio/Sources/Modules/Stat/View/ViewElements/Insulin/BolusStatsView.swift
  24. 2 2
      Trio/Sources/Modules/Stat/View/ViewElements/Insulin/TotalDailyDoseChart.swift
  25. 2 2
      Trio/Sources/Modules/Stat/View/ViewElements/Meal/MealStatsView.swift
  26. 112 13
      Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift
  27. 31 16
      Trio/Sources/Services/WatchManager/AppleWatchManager.swift
  28. 44 26
      TrioTests/GlucoseSmoothingTests.swift

+ 3 - 0
.github/FUNDING.yml

@@ -0,0 +1,3 @@
+# These are supported funding model platforms
+
+custom: ["https://www.nightscoutfoundation.org/donate"]

+ 1 - 1
.github/workflows/add_identifiers.yml

@@ -16,7 +16,7 @@ jobs:
     steps:
       # Checks-out the repo
       - name: Checkout Repo
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
 
       # Patch Fastlane Match to not print tables
       - name: Patch Match Tables

+ 1 - 1
.github/workflows/auto_version_dev.yml

@@ -52,7 +52,7 @@ jobs:
 
     steps:
       - name: Checkout repo
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
         with:
          token: ${{ secrets.TRIO_TOKEN_AUTOBUMP }}
 

+ 4 - 4
.github/workflows/build_trio.yml

@@ -90,7 +90,7 @@ jobs:
         if: |
           steps.workflow-permission.outputs.has_permission == 'true' &&
           (vars.SCHEDULED_BUILD != 'false' || vars.SCHEDULED_SYNC != 'false')
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
         with:
           token: ${{ secrets.GH_PAT }}
 
@@ -100,7 +100,7 @@ jobs:
           steps.workflow-permission.outputs.has_permission == 'true' &&
           vars.SCHEDULED_SYNC != 'false' && github.repository_owner != 'nightscout'
         id: sync
-        uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.1
+        uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.2
         with:
           target_sync_branch: ${{ env.TARGET_BRANCH }}
           shallow_since: 6 months ago
@@ -178,7 +178,7 @@ jobs:
         run: "sudo xcode-select --switch /Applications/Xcode_26.2.app/Contents/Developer"
       
       - name: Checkout Repo for building
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
         with:
           token: ${{ secrets.GH_PAT }}
           submodules: recursive
@@ -258,7 +258,7 @@ jobs:
       # Upload Build artifacts
       - name: Upload build log, IPA and Symbol artifacts
         if: always()
-        uses: actions/upload-artifact@v4
+        uses: actions/upload-artifact@v6
         with:
           name: build-artifacts
           path: |

+ 2 - 2
.github/workflows/create_certs.yml

@@ -28,7 +28,7 @@ jobs:
     steps:
       # Checks-out the repo
       - name: Checkout Repo
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
 
       # Patch Fastlane Match to not print tables
       - name: Patch Match Tables
@@ -97,7 +97,7 @@ jobs:
           run: echo "new_certificate_needed=${{ needs.create_certs.outputs.new_certificate_needed }}"
 
         - name: Checkout repository
-          uses: actions/checkout@v4
+          uses: actions/checkout@v5
 
         - name: Install dependencies
           run: bundle install

+ 2 - 2
.github/workflows/stale_issues.yml

@@ -11,7 +11,7 @@ jobs:
       pull-requests: write
     if: github.repository_owner == 'nightscout'
     steps:
-      - uses: actions/stale@v9.0.0
+      - uses: actions/stale@v10
         with:
           days-before-issue-stale: 30
           days-before-issue-close: 14
@@ -32,7 +32,7 @@ jobs:
       pull-requests: write
     if: github.repository_owner == 'nightscout'
     steps:
-      - uses: actions/stale@v9.0.0
+      - uses: actions/stale@v10
         with:
           days-before-issue-stale: 30
           days-before-issue-close: 30

+ 3 - 3
.github/workflows/unit_tests.yml

@@ -31,14 +31,14 @@ jobs:
         run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
 
       - name: Checkout code
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
         with:
           fetch-depth: 1
           submodules: recursive
 
       - name: Restore cache
         id: cache-restore
-        uses: actions/cache/restore@v4
+        uses: actions/cache/restore@v5
         with:
           path: |
             /Users/runner/Library/Developer/Xcode/DerivedData
@@ -94,7 +94,7 @@ jobs:
           
       - name: Save cache
         if: steps.cache-restore.outputs.cache-hit != 'true'
-        uses: actions/cache/save@v4
+        uses: actions/cache/save@v5
         with:
           path: |
             /Users/runner/Library/Developer/Xcode/DerivedData

+ 1 - 1
.github/workflows/validate_secrets.yml

@@ -116,7 +116,7 @@ jobs:
       TEAMID: ${{ secrets.TEAMID }}
     steps:
       - name: Checkout Repo
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
 
       - name: Install Project Dependencies
         run: bundle install

+ 1 - 1
Config.xcconfig

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

+ 2 - 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="23788.4" systemVersion="25B78" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24512" systemVersion="25D2128" 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"/>
@@ -78,7 +78,7 @@
         <attribute name="isUploadedToHealth" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
         <attribute name="isUploadedToNS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
         <attribute name="isUploadedToTidepool" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
-        <attribute name="smoothedGlucose" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
+        <attribute name="smoothedGlucose" optional="YES" attributeType="Decimal"/>
         <fetchIndex name="byDate">
             <fetchIndexElement property="date" type="Binary" order="ascending"/>
         </fetchIndex>

+ 66 - 0
Trio Watch App Extension/Helper/WatchNotificationHandler.swift

@@ -0,0 +1,66 @@
+import Foundation
+import UserNotifications
+import WatchConnectivity
+
+final class WatchNotificationHandler: NSObject, UNUserNotificationCenterDelegate {
+    static let shared = WatchNotificationHandler()
+
+    override private init() {
+        super.init()
+    }
+
+    func configure() {
+        let center = UNUserNotificationCenter.current()
+        center.delegate = self
+        registerCategories(on: center)
+    }
+
+    private func registerCategories(on center: UNUserNotificationCenter) {
+        center.getNotificationCategories { existingCategories in
+            let glucoseCategory = NotificationCategoryFactory.createGlucoseCategory()
+
+            var categories = existingCategories
+            categories.update(with: glucoseCategory)
+            // UNUserNotificationCenter methods should be called on main thread
+            Task { @MainActor in
+                center.setNotificationCategories(categories)
+            }
+        }
+    }
+
+    /// UNUserNotificationCenterDelegate method called when user interacts with a notification on watch.
+    /// This can be called off the main thread. WCSession.transferUserInfo is thread-safe.
+    func userNotificationCenter(
+        _: UNUserNotificationCenter,
+        didReceive response: UNNotificationResponse,
+        withCompletionHandler completionHandler: @escaping () -> Void
+    ) {
+        defer { completionHandler() }
+
+        guard let action = NotificationResponseAction(rawValue: response.actionIdentifier) else { return }
+        sendSnoozeRequest(for: action)
+    }
+
+    /// Sends snooze request to iPhone via WatchConnectivity.
+    /// WCSession.transferUserInfo is thread-safe and can be called from any thread.
+    /// Relies on the watch app's WCSession owner (e.g., WatchState) to handle
+    /// session activation and delegate management.
+    private func sendSnoozeRequest(for action: NotificationResponseAction) {
+        guard WCSession.isSupported() else { return }
+
+        let payload: [String: Any] = [WatchMessageKeys.snoozeDuration: action.minutes]
+        let session = WCSession.default
+
+        // Try sendMessage first if session is reachable and activated (faster, immediate delivery)
+        // Fall back to transferUserInfo if not reachable or if sendMessage fails
+        if session.isReachable, session.activationState == .activated {
+            session.sendMessage(payload, replyHandler: nil) { _ in
+                // Fallback to transferUserInfo if sendMessage fails
+                session.transferUserInfo(payload)
+            }
+        } else {
+            // Session not reachable or not activated - use transferUserInfo (queued delivery)
+            session.transferUserInfo(payload)
+        }
+    }
+}

+ 5 - 0
Trio Watch App Extension/TrioWatchApp.swift

@@ -1,8 +1,13 @@
 import SwiftUI
+import UserNotifications
 
 @main struct TrioWatchApp: App {
     @Environment(\.scenePhase) private var scenePhase
 
+    init() {
+        WatchNotificationHandler.shared.configure()
+    }
+
     var body: some Scene {
         WindowGroup {
             TrioMainWatchView()

+ 10 - 0
Trio.xcodeproj/project.pbxproj

@@ -463,6 +463,9 @@
 		C2AA6CF72E1A734A00BF6C16 /* SettingsExportProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2AA6CF32E1A734A00BF6C16 /* SettingsExportProvider.swift */; };
 		C2AA6CF82E1A734A00BF6C16 /* SettingsExportDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2AA6CF22E1A734A00BF6C16 /* SettingsExportDataFlow.swift */; };
 		C2AA6CF92E1A734A00BF6C16 /* SettingsExportStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2AA6CF42E1A734A00BF6C16 /* SettingsExportStateModel.swift */; };
+		C2BA6B972F758E7500348E6A /* WatchNotificationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BA6B962F758E7500348E6A /* WatchNotificationHandler.swift */; };
+		C2BA6B992F758E7600348E6A /* NotificationIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BA6B982F758E7600348E6A /* NotificationIdentifiers.swift */; };
+		C2BA6B9A2F7593C300348E6A /* NotificationIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BA6B982F758E7600348E6A /* NotificationIdentifiers.swift */; };
 		C967DACD3B1E638F8B43BE06 /* ManualTempBasalStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFCFE0781F9074C2917890E8 /* ManualTempBasalStateModel.swift */; };
 		CA370FC152BC98B3D1832968 /* BasalProfileEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF8BCB0C37DEB5EC377B9612 /* BasalProfileEditorRootView.swift */; };
 		CC6C406E2ACDD69E009B8058 /* RawFetchedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */; };
@@ -1300,6 +1303,8 @@
 		C2AA6CF22E1A734A00BF6C16 /* SettingsExportDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsExportDataFlow.swift; sourceTree = "<group>"; };
 		C2AA6CF32E1A734A00BF6C16 /* SettingsExportProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsExportProvider.swift; sourceTree = "<group>"; };
 		C2AA6CF42E1A734A00BF6C16 /* SettingsExportStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsExportStateModel.swift; sourceTree = "<group>"; };
+		C2BA6B962F758E7500348E6A /* WatchNotificationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchNotificationHandler.swift; sourceTree = "<group>"; };
+		C2BA6B982F758E7600348E6A /* NotificationIdentifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationIdentifiers.swift; sourceTree = "<group>"; };
 		C377490C77661D75E8C50649 /* ManualTempBasalRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ManualTempBasalRootView.swift; sourceTree = "<group>"; };
 		C8D1A7CA8C10C4403D4BBFA7 /* TreatmentsDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TreatmentsDataFlow.swift; sourceTree = "<group>"; };
 		CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RawFetchedProfile.swift; sourceTree = "<group>"; };
@@ -2439,6 +2444,7 @@
 				DD6B7CB32C7B71F700B75029 /* ForecastDisplayType.swift */,
 				DD9ECB692CA99F6C00AA7C45 /* CommandPayload.swift */,
 				DDD6D4D22CDE90720029439A /* EstimatedA1cDisplayUnit.swift */,
+				C2BA6B982F758E7600348E6A /* NotificationIdentifiers.swift */,
 			);
 			path = Models;
 			sourceTree = "<group>";
@@ -3390,6 +3396,7 @@
 				DD09D5C62D29EB26000D82C9 /* Helper+Enums.swift */,
 				DD3A3CE82D29C97800AE478E /* Helper+ButtonStyles.swift */,
 				DD3A3CE62D29C93F00AE478E /* Helper+Extensions.swift */,
+				C2BA6B962F758E7500348E6A /* WatchNotificationHandler.swift */,
 			);
 			path = Helper;
 			sourceTree = "<group>";
@@ -4511,6 +4518,7 @@
 				53F2382465BF74DB1A967C8B /* PumpConfigProvider.swift in Sources */,
 				5D16287A969E64D18CE40E44 /* PumpConfigStateModel.swift in Sources */,
 				DDF6902C2DA028D3008BF16C /* DiagnosticsStepView.swift in Sources */,
+				C2BA6B992F758E7600348E6A /* NotificationIdentifiers.swift in Sources */,
 				19D466AA29AA3099004D5F33 /* MealSettingsRootView.swift in Sources */,
 				CEF1ED6B2D58FB5800FAF41E /* CGMOptions.swift in Sources */,
 				E974172296125A5AE99E634C /* PumpConfigRootView.swift in Sources */,
@@ -4816,6 +4824,7 @@
 				BDA25F202D26D5FE00035F34 /* CarbsInputView.swift in Sources */,
 				BDA25F1C2D26BD0700035F34 /* TrendShape.swift in Sources */,
 				BDFF7A892D25F97D0016C40C /* TrioWatchApp.swift in Sources */,
+				C2BA6B972F758E7500348E6A /* WatchNotificationHandler.swift in Sources */,
 				DD09D5C72D29EB2F000D82C9 /* Helper+Enums.swift in Sources */,
 				BD54A9742D281AEF00F9C1EE /* TempTargetPresetWatch.swift in Sources */,
 				BD54A95C2D2808A300F9C1EE /* OverridePresetWatch.swift in Sources */,
@@ -4826,6 +4835,7 @@
 				DD3A3CE72D29C93F00AE478E /* Helper+Extensions.swift in Sources */,
 				DD246F062D2836AA0027DDE0 /* GlucoseTrendView.swift in Sources */,
 				BD432CA22D2F4E4000D1EB79 /* WatchMessageKeys.swift in Sources */,
+				C2BA6B9A2F7593C300348E6A /* NotificationIdentifiers.swift in Sources */,
 				DD8262CB2D289297009F6F62 /* BolusConfirmationView.swift in Sources */,
 				BD54A9712D281A8100F9C1EE /* TempTargetPresetsView.swift in Sources */,
 				DD3A3CE92D29C97800AE478E /* Helper+ButtonStyles.swift in Sources */,

+ 38 - 33
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -240,37 +240,6 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         return Manager.init(rawState: rawState)
     }
 
-    func fetchGlucose(context: NSManagedObjectContext) async throws -> [NSManagedObjectID] {
-        // Compound predicate: time window + non-manual + valid date
-        let timePredicate = NSPredicate.predicateForOneDayAgoInMinutes
-        let manualPredicate = NSPredicate(format: "isManual == NO")
-        let datePredicate = NSPredicate(format: "date != nil")
-
-        let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
-            timePredicate,
-            manualPredicate,
-            datePredicate
-        ])
-
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: GlucoseStored.self,
-            onContext: context,
-            // Predicate must cover at least the full glucose horizon used by downstream algorithm consumers.
-            // If autosens / oref / smoothing logic ever starts looking back further (e.g. 36h),
-            // this fetch window must be expanded accordingly.
-            predicate: compoundPredicate,
-            key: "date",
-            ascending: true, // the first element is the oldest
-            fetchLimit: 350
-        )
-
-        guard let glucoseArray = results as? [GlucoseStored] else {
-            throw CoreDataError.fetchError(function: #function, file: #file)
-        }
-
-        return glucoseArray.map(\.objectID)
-    }
-
     private func glucoseStoreAndHeartDecision(syncDate: Date, glucose: [BloodGlucose]) async throws {
         // calibration add if required only for sensor
         let newGlucose = overcalibrate(entries: glucose)
@@ -394,6 +363,37 @@ extension BaseFetchGlucoseManager: SettingsObserver {
 }
 
 extension BaseFetchGlucoseManager {
+    func fetchGlucose(context: NSManagedObjectContext) async throws -> [NSManagedObjectID] {
+        // Compound predicate: time window + non-manual + valid date
+        let timePredicate = NSPredicate.predicateForOneDayAgoInMinutes
+        let manualPredicate = NSPredicate(format: "isManual == NO")
+        let datePredicate = NSPredicate(format: "date != nil")
+
+        let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
+            timePredicate,
+            manualPredicate,
+            datePredicate
+        ])
+
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: context,
+            // Predicate must cover at least the full glucose horizon used by downstream algorithm consumers.
+            // If autosens / oref / smoothing logic ever starts looking back further (e.g. 36h),
+            // this fetch window must be expanded accordingly.
+            predicate: compoundPredicate,
+            key: "date",
+            ascending: true, // the first element is the oldest
+            fetchLimit: 350
+        )
+
+        guard let glucoseArray = results as? [GlucoseStored] else {
+            throw CoreDataError.fetchError(function: #function, file: #file)
+        }
+
+        return glucoseArray.map(\.objectID)
+    }
+
     /// CoreData-friendly AAPS exponential smoothing + storage.
     /// - Important: Only stores `smoothedGlucose`. UI/alerts should still use `glucose`.
     ///
@@ -474,12 +474,17 @@ extension BaseFetchGlucoseManager {
             }
         }
 
-        // If insufficient valid readings: copy raw into smoothed (clamped) for all passed entries.
+        // Not enough recent contiguous readings to smooth (e.g. after CGM gap).
+        // IMPORTANT: Only apply fallback to the recent window, not all data.
+        // Otherwise a recent gap would overwrite historical smoothed values.
         guard validWindowCount >= minimumWindowSize else {
-            for object in data {
+            let recentWindow = data.suffix(validWindowCount)
+
+            for object in recentWindow {
                 let raw = Decimal(Int(object.glucose))
                 object.smoothedGlucose = max(raw, minimumSmoothedGlucose) as NSDecimalNumber
             }
+
             return
         }
 

+ 1 - 1
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -132,7 +132,7 @@ final class OpenAPS {
 
             return glucoseResults.map { glucose -> AlgorithmGlucose in
                 let glucoseValue: Int16
-                if shouldSmoothGlucose, !glucose.isManual, let smoothedGlucose = glucose.smoothedGlucose {
+                if shouldSmoothGlucose, !glucose.isManual, let smoothedGlucose = glucose.smoothedGlucose, smoothedGlucose != 0 {
                     glucoseValue = smoothedGlucose.rounding(accordingToBehavior: roundingBehavior).int16Value
                 } else {
                     glucoseValue = glucose.glucose

+ 6 - 3
Trio/Sources/APS/Storage/PumpHistoryStorage.swift

@@ -291,13 +291,16 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     }
 
     func determineBolusEventType(for event: PumpEventStored) -> PumpEventStored.EventType {
-        if event.bolus!.isSMB {
+        guard let bolus = event.bolus else {
+            return event.type.flatMap({ PumpEventStored.EventType(rawValue: $0) }) ?? .bolus
+        }
+        if bolus.isSMB {
             return .smb
         }
-        if event.bolus!.isExternal {
+        if bolus.isExternal {
             return .isExternal
         }
-        return PumpEventStored.EventType(rawValue: event.type!) ?? PumpEventStored.EventType.bolus
+        return event.type.flatMap({ PumpEventStored.EventType(rawValue: $0) }) ?? .bolus
     }
 
     func getPumpHistoryNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {

+ 19 - 80
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -20239,6 +20239,9 @@
       "comment" : "Name of the bolus display threshold option that represents 0.5 units.",
       "isCommentAutoGenerated" : true
     },
+    "1 hour" : {
+      "comment" : "Snooze glucose alerts for 1 hour"
+    },
     "1 U and over" : {
       "comment" : "Name to display for the \"1 U and over\" bolus display threshold option.",
       "isCommentAutoGenerated" : true
@@ -20722,6 +20725,9 @@
         }
       }
     },
+    "3 hours" : {
+      "comment" : "Snooze glucose alerts for 3 hours"
+    },
     "3 M" : {
       "comment" : "Abbreviation for three months",
       "localizations" : {
@@ -22160,6 +22166,9 @@
         }
       }
     },
+    "20 min" : {
+      "comment" : "Snooze glucose alerts for 20 minutes"
+    },
     "24 hours" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -265737,120 +265746,50 @@
         }
       }
     },
-    "Very Low (<%@" : {
+    "Very Low (<%@)" : {
+      "comment" : "A description of a glucose range.",
+      "isCommentAutoGenerated" : true,
       "localizations" : {
-        "bg" : {
-          "stringUnit" : {
-            "state" : "new",
-            "value" : "Very Low (<%@"
-          }
-        },
         "da" : {
           "stringUnit" : {
             "state" : "translated",
-            "value" : "Meget Lav (<%@"
+            "value" : "Meget Lav (<%@)"
           }
         },
         "de" : {
           "stringUnit" : {
             "state" : "translated",
-            "value" : "Sehr niedrig (<%@"
-          }
-        },
-        "es" : {
-          "stringUnit" : {
-            "state" : "new",
-            "value" : "Very Low (<%@"
+            "value" : "Sehr niedrig (<%@)"
           }
         },
         "fr" : {
           "stringUnit" : {
             "state" : "translated",
-            "value" : "Très bas (<%@"
-          }
-        },
-        "he" : {
-          "stringUnit" : {
-            "state" : "new",
-            "value" : "Very Low (<%@"
-          }
-        },
-        "it" : {
-          "stringUnit" : {
-            "state" : "new",
-            "value" : "Very Low (<%@"
-          }
-        },
-        "ko" : {
-          "stringUnit" : {
-            "state" : "new",
-            "value" : "Very Low (<%@"
+            "value" : "Très bas (<%@)"
           }
         },
         "nb-NO" : {
           "stringUnit" : {
             "state" : "translated",
-            "value" : "Veldig lavt (<%@"
-          }
-        },
-        "nl" : {
-          "stringUnit" : {
-            "state" : "new",
-            "value" : "Very Low (<%@"
+            "value" : "Veldig lavt (<%@)"
           }
         },
         "pl" : {
           "stringUnit" : {
             "state" : "translated",
-            "value" : "Bardzo niski (<%@"
-          }
-        },
-        "pt-PT" : {
-          "stringUnit" : {
-            "state" : "new",
-            "value" : "Very Low (<%@"
-          }
-        },
-        "ro" : {
-          "stringUnit" : {
-            "state" : "new",
-            "value" : "Very Low (<%@"
-          }
-        },
-        "ru" : {
-          "stringUnit" : {
-            "state" : "new",
-            "value" : "Very Low (<%@"
-          }
-        },
-        "sv" : {
-          "stringUnit" : {
-            "state" : "new",
-            "value" : "Very Low (<%@"
-          }
-        },
-        "tr" : {
-          "stringUnit" : {
-            "state" : "new",
-            "value" : "Very Low (<%@"
-          }
-        },
-        "uk" : {
-          "stringUnit" : {
-            "state" : "new",
-            "value" : "Very Low (<%@"
+            "value" : "Bardzo niski (<%@)"
           }
         },
         "vi" : {
           "stringUnit" : {
             "state" : "translated",
-            "value" : "Qua thấp (<%@"
+            "value" : "Qua thấp (<%@)"
           }
         },
         "zh-Hant" : {
           "stringUnit" : {
             "state" : "translated",
-            "value" : "非常低(<%@"
+            "value" : "非常低(<%@)"
           }
         }
       }

+ 64 - 0
Trio/Sources/Models/NotificationIdentifiers.swift

@@ -0,0 +1,64 @@
+import Foundation
+import UserNotifications
+
+enum NotificationCategoryIdentifier: String {
+    case trioAlert = "Trio.alert"
+}
+
+enum NotificationResponseAction: String, CaseIterable {
+    case snooze20 = "Trio.snooze20"
+    case snooze1hr = "Trio.snooze1hr"
+    case snooze3hr = "Trio.snooze3hr"
+    case snooze6hr = "Trio.snooze6hr"
+
+    var duration: TimeInterval {
+        TimeInterval(minutes) * 60
+    }
+
+    var minutes: Int {
+        switch self {
+        case .snooze20:
+            return 20
+        case .snooze1hr:
+            return 60
+        case .snooze3hr:
+            return 180
+        case .snooze6hr:
+            return 360
+        }
+    }
+
+    var localizedTitle: String {
+        switch self {
+        case .snooze20:
+            return String(localized: "20 min", comment: "Snooze glucose alerts for 20 minutes")
+        case .snooze1hr:
+            return String(localized: "1 hour", comment: "Snooze glucose alerts for 1 hour")
+        case .snooze3hr:
+            return String(localized: "3 hours", comment: "Snooze glucose alerts for 3 hours")
+        case .snooze6hr:
+            return String(localized: "6 hours", comment: "Snooze glucose alerts for 6 hours")
+        }
+    }
+}
+
+// MARK: - NotificationCategoryFactory
+
+enum NotificationCategoryFactory {
+    static func createGlucoseCategory() -> UNNotificationCategory {
+        let snoozeActions = NotificationResponseAction.allCases.map { action in
+            UNNotificationAction(
+                identifier: action.rawValue,
+                title: action.localizedTitle,
+                options: []
+            )
+        }
+
+        return UNNotificationCategory(
+            identifier: NotificationCategoryIdentifier.trioAlert.rawValue,
+            actions: snoozeActions,
+            intentIdentifiers: [],
+            options: []
+        )
+    }
+}

+ 3 - 0
Trio/Sources/Models/WatchMessageKeys.swift

@@ -51,4 +51,7 @@ enum WatchMessageKeys {
     static let maxProtein = "maxProtein"
     static let bolusIncrement = "bolusIncrement"
     static let confirmBolusFaster = "confirmBolusFaster"
+
+    // Notification Actions
+    static let snoozeDuration = "snoozeDuration"
 }

+ 31 - 2
Trio/Sources/Modules/Snooze/SnoozeStateModel.swift

@@ -4,12 +4,41 @@ import SwiftUI
 extension Snooze {
     @Observable final class StateModel: BaseStateModel<Provider> {
         @ObservationIgnored @Persisted(key: "UserNotificationsManager.snoozeUntilDate") var snoozeUntilDate: Date = .distantPast
-        @ObservationIgnored @Injected() var glucoseStogare: GlucoseStorage!
+        @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage!
+        @ObservationIgnored @Injected() var notificationsManager: UserNotificationsManager!
+        @ObservationIgnored @Injected() var broadcaster: Broadcaster!
 
         var alarm: GlucoseAlarm?
 
         override func subscribe() {
-            alarm = glucoseStogare.alarm
+            alarm = glucoseStorage.alarm
+            broadcaster.register(SnoozeObserver.self, observer: self)
         }
+
+        func unsubscribe() {
+            broadcaster.unregister(SnoozeObserver.self, observer: self)
+        }
+
+        // Add validation helper inside the class
+        private func validateSnoozeDuration(_ duration: TimeInterval) -> Bool {
+            // Only allow durations matching our defined actions
+            NotificationResponseAction.allCases
+                .map(\.duration)
+                .contains(duration)
+        }
+
+        @MainActor func applySnooze(_ duration: TimeInterval) async {
+            // Allow any duration chosen in the Snooze UI, while keeping validation for quick actions elsewhere.
+            snoozeUntilDate = duration > 0 ? Date().addingTimeInterval(duration) : .distantPast
+            alarm = glucoseStorage.alarm
+            await notificationsManager.applySnooze(for: duration)
+        }
+    }
+}
+
+extension Snooze.StateModel: SnoozeObserver {
+    func snoozeDidChange(_ untilDate: Date) {
+        snoozeUntilDate = untilDate
+        alarm = glucoseStorage.alarm
     }
 }

+ 26 - 30
Trio/Sources/Modules/Snooze/View/SnoozeRootView.swift

@@ -14,29 +14,12 @@ extension Snooze {
         @State private var snoozeDescription = "nothing to see here"
 
         private var pickerTimes: [TimeInterval] {
-            var arr: [TimeInterval] = []
-
-            let mins10 = 0.166_67
-            let mins20 = mins10 * 2
-            let mins30 = mins10 * 3
-            // let mins40 = mins10 * 4
-
-            for hr in 0 ..< 2 {
-                for min in [0.0, mins20, mins20 * 2] {
-                    arr.append(TimeInterval(hours: Double(hr) + min))
-                }
-            }
-            for hr in 2 ..< 4 {
-                for min in [0.0, mins30] {
-                    arr.append(TimeInterval(hours: Double(hr) + min))
-                }
-            }
-
-            for hr in 4 ... 8 {
-                arr.append(TimeInterval(hours: Double(hr)))
-            }
-
-            return arr
+            [
+                TimeInterval(minutes: 20), // 20 minutes
+                TimeInterval(hours: 1), // 1 hour
+                TimeInterval(hours: 3), // 3 hours
+                TimeInterval(hours: 6) // 6 hours
+            ]
         }
 
         private var formatter: DateComponentsFormatter {
@@ -53,7 +36,7 @@ extension Snooze {
         }
 
         private func formatInterval(_ interval: TimeInterval) -> String {
-            formatter.string(from: interval)!
+            formatter.string(from: interval) ?? ""
         }
 
         func getSnoozeDescription() -> String {
@@ -85,12 +68,16 @@ extension Snooze {
             VStack(alignment: .leading) {
                 Button {
                     let interval = pickerTimes[selectedInterval]
-                    let snoozeFor = formatter.string(from: interval)!
+                    let snoozeFor = formatInterval(interval)
                     let untilDate = Date() + interval
-                    state.snoozeUntilDate = untilDate < Date() ? .distantPast : untilDate
-                    debug(.default, "will snooze for \(snoozeFor) until \(dateFormatter.string(from: untilDate))")
-                    snoozeDescription = getSnoozeDescription()
-                    state.hideModal()
+
+                    Task { @MainActor [weak state] in
+                        guard let state = state else { return }
+                        await state.applySnooze(interval)
+                        debug(.default, "will snooze for \(snoozeFor) until \(dateFormatter.string(from: untilDate))")
+                        snoozeDescription = getSnoozeDescription()
+                        state.hideModal()
+                    }
                 } label: {
                     Text("Click to Snooze Alerts")
                         .padding()
@@ -120,11 +107,20 @@ extension Snooze {
             .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
             .navigationBarTitle("Snooze Alerts")
             .navigationBarTitleDisplayMode(.automatic)
-            .navigationBarItems(trailing: Button("Close", action: state.hideModal))
+            .toolbar {
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button("Close") {
+                        state.hideModal()
+                    }
+                }
+            }
             .onAppear {
                 configureView()
                 snoozeDescription = getSnoozeDescription()
             }
+            .onDisappear {
+                state.unsubscribe()
+            }
         }
     }
 }

+ 1 - 1
Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift

@@ -343,7 +343,7 @@ struct GlucoseSectorChart: View {
                         formatPercentage(Decimal(low) / total * 100)
                     ),
                     (
-                        String(localized: "Very Low (<\(Decimal(54).formatted(for: units))"),
+                        String(localized: "Very Low (<\(Decimal(54).formatted(for: units)))"),
                         formatPercentage(Decimal(veryLow) / total * 100)
                     ),
                     (String(localized: "Average"), average.formatted(for: units)),

+ 2 - 2
Trio/Sources/Modules/Stat/View/ViewElements/Insulin/BolusStatsView.swift

@@ -272,9 +272,9 @@ struct BolusStatsView: View {
                             AxisGridLine()
                         }
                     case .total:
-                        // Only show every other month
+                        // Show start of every month
                         let day = Calendar.current.component(.day, from: date)
-                        if day == 1 && Calendar.current.component(.month, from: date) % 2 == 1 {
+                        if day == 1 {
                             AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
                                 .font(.footnote)
                             AxisGridLine()

+ 2 - 2
Trio/Sources/Modules/Stat/View/ViewElements/Insulin/TotalDailyDoseChart.swift

@@ -224,8 +224,8 @@ struct TotalDailyDoseChart: View {
                             AxisGridLine()
                         }
                     case .total:
-                        // Only show every other month
-                        if day == 1 && Calendar.current.component(.month, from: date) % 2 == 1 {
+                        // Show start of every month
+                        if day == 1 {
                             AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
                                 .font(.footnote)
                             AxisGridLine()

+ 2 - 2
Trio/Sources/Modules/Stat/View/ViewElements/Meal/MealStatsView.swift

@@ -254,9 +254,9 @@ struct MealStatsView: View {
                             AxisGridLine()
                         }
                     case .total:
-                        // Only show every other month
+                        // Show start of every month
                         let day = Calendar.current.component(.day, from: date)
-                        if day == 1 && Calendar.current.component(.month, from: date) % 2 == 1 {
+                        if day == 1 {
                             AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
                                 .font(.footnote)
                             AxisGridLine()

+ 112 - 13
Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift

@@ -11,6 +11,7 @@ import UserNotifications
 protocol UserNotificationsManager {
     func getNotificationSettings(completionHandler: @escaping (UNNotificationSettings) -> Void)
     func requestNotificationPermissions(completion: @escaping (Bool) -> Void)
+    @MainActor func applySnooze(for duration: TimeInterval) async
 }
 
 enum GlucoseSourceKey: String {
@@ -40,6 +41,12 @@ protocol pumpNotificationObserver {
     func pumpRemoveNotification()
 }
 
+// MARK: - SnoozeObserver Protocol
+
+protocol SnoozeObserver {
+    @MainActor func snoozeDidChange(_ untilDate: Date)
+}
+
 final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, Injectable {
     enum Identifier: String {
         case glucoseNotification = "Trio.glucoseNotification"
@@ -61,6 +68,9 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     @Injected(as: FetchGlucoseManager.self) private var sourceInfoProvider: SourceInfoProvider!
 
     @Persisted(key: "UserNotificationsManager.snoozeUntilDate") private var snoozeUntilDate: Date = .distantPast
+    // The glucose notification observers below (Core Data saves and the storage publisher) can fire for the same
+    // reading, so we persist the last alert token to avoid enqueueing identical high/low notifications multiple times.
+    @Persisted(key: "UserNotificationsManager.lastGlucoseAlertToken") private var lastGlucoseAlertToken: String = ""
 
     private let notificationCenter = UNUserNotificationCenter.current()
     private var lifetime = Lifetime()
@@ -95,11 +105,28 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         Task {
             await sendGlucoseNotification()
         }
+        configureNotificationCategories()
         registerHandlers()
         registerSubscribers()
         subscribeOnLoop()
     }
 
+    private func configureNotificationCategories() {
+        notificationCenter.getNotificationCategories { [weak self] existingCategories in
+            guard let self else { return }
+
+            let glucoseCategory = NotificationCategoryFactory.createGlucoseCategory()
+
+            var categories = existingCategories
+            categories.update(with: glucoseCategory)
+            // UNUserNotificationCenter methods should be called on main thread
+            Task { @MainActor [weak self] in
+                guard let self else { return }
+                self.notificationCenter.setNotificationCategories(categories)
+            }
+        }
+    }
+
     private func subscribeOnLoop() {
         apsManager.lastLoopDateSubject
             .sink { [weak self] date in
@@ -271,6 +298,10 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                 try viewContext.existingObject(with: id) as? GlucoseStored
             }
 
+            if glucoseStorage.alarm == .none {
+                lastGlucoseAlertToken = ""
+            }
+
             guard let lastReading = glucoseObjects.first?.glucose,
                   let secondLastReading = glucoseObjects.dropFirst().first?.glucose,
                   let lastDirection = glucoseObjects.first?.directionEnum?.symbol else { return }
@@ -305,6 +336,15 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                 titles.append(String(localized: "(Snoozed)", comment: "(Snoozed)"))
                 notificationAlarm = false
             } else {
+                let token = alertToken(from: glucoseObjects.first)
+
+                if token == "unknown" {
+                    warning(.service, "Missing glucose token fields; skipping notification to avoid re-alerting")
+                    return
+                }
+                if notificationAlarm, token == lastGlucoseAlertToken {
+                    return
+                }
                 titles.append(body)
                 let content = UNMutableNotificationContent()
                 content.title = titles.joined(separator: " ")
@@ -313,6 +353,7 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                 if notificationAlarm {
                     content.sound = .default
                     content.userInfo[NotificationAction.key] = NotificationAction.snooze.rawValue
+                    content.categoryIdentifier = NotificationCategoryIdentifier.trioAlert.rawValue
                 }
 
                 addRequest(
@@ -323,6 +364,9 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                     messageSubtype: .glucose,
                     action: NotificationAction.snooze
                 )
+                if notificationAlarm {
+                    lastGlucoseAlertToken = token
+                }
             }
         } catch {
             debugPrint(
@@ -331,6 +375,23 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         }
     }
 
+    private func alertToken(from glucose: GlucoseStored?) -> String {
+        if let id = glucose?.id?.uuidString { return id }
+
+        if let date = glucose?.date {
+            let roundedMinute = Int((date.timeIntervalSince1970 / 60).rounded())
+            return "date-\(roundedMinute)"
+        }
+
+        // Stable fallback for Core Data objects:
+        if let glucose, !glucose.objectID.isTemporaryID {
+            return "objectID-\(glucose.objectID.uriRepresentation().absoluteString)"
+        }
+
+        // Stable “unknown” fallback: prevents repeated alarms when identifiers are missing
+        return "unknown"
+    }
+
     private func glucoseText(glucoseValue: Int, delta: Int?, direction: String?) -> String {
         let units = settingsManager.settings.units
         let glucoseText = glucoseFormatter
@@ -409,6 +470,19 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         }
     }
 
+    @MainActor func applySnooze(for duration: TimeInterval) async {
+        let untilDate = duration > 0 ? Date().addingTimeInterval(duration) : .distantPast
+        snoozeUntilDate = untilDate
+        lastGlucoseAlertToken = ""
+        // removeGlucoseNotifications() is safe to call here since we're @MainActor
+        removeGlucoseNotifications()
+
+        // Notify observers that snooze was applied
+        broadcaster.notify(SnoozeObserver.self, on: .main) { (observer: SnoozeObserver) in
+            observer.snoozeDidChange(untilDate)
+        }
+    }
+
     private func addRequest(
         identifier: Identifier,
         content: UNMutableNotificationContent,
@@ -571,6 +645,14 @@ extension BaseUserNotificationsManager: pumpNotificationObserver {
             self.notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier.rawValue])
         }
     }
+
+    /// Removes all glucose notifications (delivered and pending).
+    /// Must be called from the main thread. Safe to call from @MainActor contexts.
+    @MainActor private func removeGlucoseNotifications() {
+        let identifier = Identifier.glucoseNotification.rawValue
+        notificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier])
+        notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier])
+    }
 }
 
 extension BaseUserNotificationsManager: DeterminationObserver {
@@ -595,29 +677,46 @@ extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate {
         completionHandler([.banner, .badge, .sound, .list])
     }
 
+    /// UNUserNotificationCenterDelegate method called when user interacts with a notification.
+    /// This can be called off the main thread, so we ensure all work happens on @MainActor.
     func userNotificationCenter(
         _: UNUserNotificationCenter,
         didReceive response: UNNotificationResponse,
         withCompletionHandler completionHandler: @escaping () -> Void
     ) {
         defer { completionHandler() }
+
+        // Handle quick snooze actions (from notification action buttons)
+        if let quickAction = NotificationResponseAction(rawValue: response.actionIdentifier) {
+            Task { @MainActor [weak self] in
+                guard let self else { return }
+                await self.applySnooze(for: quickAction.duration)
+            }
+            return
+        }
+
+        // Handle other notification actions (e.g., tapping notification body)
         guard let actionRaw = response.notification.request.content.userInfo[NotificationAction.key] as? String,
               let action = NotificationAction(rawValue: actionRaw)
         else { return }
 
-        switch action {
-        case .snooze:
-            router.mainModalScreen.send(.snooze)
-        case .pumpConfig:
-            let messageCont = MessageContent(
-                content: response.notification.request.content.body,
-                type: MessageType.other,
-                subtype: .pump,
-                useAPN: false,
-                action: .pumpConfig
-            )
-            router.alertMessage.send(messageCont)
-        default: break
+        // Ensure UI operations happen on main thread using Task for consistency
+        Task { @MainActor [weak self] in
+            guard let self = self else { return }
+            switch action {
+            case .snooze:
+                self.router.mainModalScreen.send(.snooze)
+            case .pumpConfig:
+                let messageCont = MessageContent(
+                    content: response.notification.request.content.body,
+                    type: MessageType.other,
+                    subtype: .pump,
+                    useAPN: false,
+                    action: .pumpConfig
+                )
+                self.router.alertMessage.send(messageCont)
+            default: break
+            }
         }
     }
 }

+ 31 - 16
Trio/Sources/Services/WatchManager/AppleWatchManager.swift

@@ -25,6 +25,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
     @Injected() private var tempTargetStorage: TempTargetsStorage!
     @Injected() private var bolusCalculationManager: BolusCalculationManager!
     @Injected() private var iobService: IOBService!
+    @Injected() private var notificationsManager: UserNotificationsManager!
 
     private var units: GlucoseUnits = .mgdL
     private var glucoseColorScheme: GlucoseColorScheme = .staticColor
@@ -553,16 +554,18 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
     }
 
     func session(_: WCSession, didReceiveMessage message: [String: Any]) {
-        DispatchQueue.main.async { [weak self] in
-            if let logs = message["watchLogs"] as? String {
-                SimpleLogReporter.appendToWatchLog(logs)
-            }
+        // Handle logs first - doesn't need self, so it can run even during teardown
+        if let logs = message["watchLogs"] as? String {
+            SimpleLogReporter.appendToWatchLog(logs)
+        }
+
+        Task { @MainActor [weak self] in
+            guard let self else { return }
 
             if let requestWatchUpdate = message[WatchMessageKeys.requestWatchUpdate] as? String,
                requestWatchUpdate == WatchMessageKeys.watchState
             {
                 debug(.watchManager, "📱 Watch requested watch state data update.")
-                guard let self = self else { return }
                 // Skip if no watch is paired or app not installed
                 guard let session = self.session, session.isPaired, session.isReachable,
                       session.isWatchAppInstalled else { return }
@@ -573,19 +576,23 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                 return
             }
 
-            if let bolusAmount = message[WatchMessageKeys.bolus] as? Double,
-               message[WatchMessageKeys.carbs] == nil,
-               message[WatchMessageKeys.date] == nil
+            if let snoozeMinutes = message[WatchMessageKeys.snoozeDuration] as? Int {
+                debug(.watchManager, "📱 Received snooze request from watch: \(snoozeMinutes) minutes")
+                await self.notificationsManager.applySnooze(for: TimeInterval(snoozeMinutes * 60))
+                return
+            } else if let bolusAmount = message[WatchMessageKeys.bolus] as? Double,
+                      message[WatchMessageKeys.carbs] == nil,
+                      message[WatchMessageKeys.date] == nil
             {
                 debug(.watchManager, "📱 Received bolus request from watch: \(bolusAmount)U")
-                self?.handleBolusRequest(Decimal(bolusAmount))
+                self.handleBolusRequest(Decimal(bolusAmount))
             } else if let carbsAmount = message[WatchMessageKeys.carbs] as? Int,
                       let timestamp = message[WatchMessageKeys.date] as? TimeInterval,
                       message[WatchMessageKeys.bolus] == nil
             {
                 let date = Date(timeIntervalSince1970: timestamp)
                 debug(.watchManager, "📱 Received carbs request from watch: \(carbsAmount)g at \(date)")
-                self?.handleCarbsRequest(carbsAmount, date)
+                self.handleCarbsRequest(carbsAmount, date)
             } else if let bolusAmount = message[WatchMessageKeys.bolus] as? Double,
                       let carbsAmount = message[WatchMessageKeys.carbs] as? Int,
                       let timestamp = message[WatchMessageKeys.date] as? TimeInterval
@@ -595,11 +602,11 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                     .watchManager,
                     "📱 Received meal bolus combo request from watch: \(bolusAmount)U, \(carbsAmount)g at \(date)"
                 )
-                self?.handleCombinedRequest(bolusAmount: Decimal(bolusAmount), carbsAmount: Decimal(carbsAmount), date: date)
+                self.handleCombinedRequest(bolusAmount: Decimal(bolusAmount), carbsAmount: Decimal(carbsAmount), date: date)
             } else {
                 debug(.watchManager, "📱 Invalid or incomplete data received from watch. Received:  \(message)")
                 // Acknowledge failure
-                self?.sendAcknowledgment(
+                self.sendAcknowledgment(
                     toWatch: false,
                     message: "Error! Invalid or incomplete data received from watch.",
                     ackCode: .genericFailure
@@ -608,22 +615,22 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
             if message[WatchMessageKeys.cancelOverride] as? Bool == true {
                 debug(.watchManager, "📱 Received cancel override request from watch")
-                self?.handleCancelOverride()
+                self.handleCancelOverride()
             }
 
             if let presetName = message[WatchMessageKeys.activateOverride] as? String {
                 debug(.watchManager, "📱 Received activate override request from watch for preset: \(presetName)")
-                self?.handleActivateOverride(presetName)
+                self.handleActivateOverride(presetName)
             }
 
             if let presetName = message[WatchMessageKeys.activateTempTarget] as? String {
                 debug(.watchManager, "📱 Received activate temp target request from watch for preset: \(presetName)")
-                self?.handleActivateTempTarget(presetName)
+                self.handleActivateTempTarget(presetName)
             }
 
             if message[WatchMessageKeys.cancelTempTarget] as? Bool == true {
                 debug(.watchManager, "📱 Received cancel temp target request from watch")
-                self?.handleCancelTempTarget()
+                self.handleCancelTempTarget()
             }
 
             if message[WatchMessageKeys.requestBolusRecommendation] as? Bool == true {
@@ -684,6 +691,14 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
         if let logs = userInfo["watchLogs"] as? String {
             SimpleLogReporter.appendToWatchLog(logs)
         }
+
+        if let snoozeMinutes = userInfo[WatchMessageKeys.snoozeDuration] as? Int {
+            debug(.watchManager, "📱 Received snooze userInfo from watch: \(snoozeMinutes) minutes")
+            Task { @MainActor [weak self] in
+                guard let self else { return }
+                await self.notificationsManager.applySnooze(for: TimeInterval(snoozeMinutes * 60))
+            }
+        }
     }
 
     #if os(iOS)

+ 44 - 26
TrioTests/GlucoseSmoothingTests.swift

@@ -120,46 +120,64 @@ import Testing
     }
 
     @Test(
-        "Exponential smoothing stops window at gaps >= 12 minutes; fallback fills smoothed glucose"
-    ) func testExponentialSmoothingGapStopsWindow() async throws {
-        // GIVEN:
+        "Exponential smoothing stops at gaps >= 12 minutes and only updates the most recent window"
+    )  func testExponentialSmoothingGapStopsWindow() async throws {
         let now = Date()
-        let dates: [Date] = [
-            now.addingTimeInterval(0), // oldest
-            now.addingTimeInterval(5 * 60),
-            now.addingTimeInterval(10 * 60),
-            now.addingTimeInterval(25 * 60), // gap of 15 minutes
-            now.addingTimeInterval(30 * 60),
-            now.addingTimeInterval(35 * 60) // newest
-        ]
-        let values: [Int16] = [100, 105, 110, 115, 120, 125]
+
+        var dates: [Date] = []
+        var values: [Int16] = []
+
+        // Older contiguous block (should remain untouched)
+        for i in 0 ..< 10 {
+            dates.append(now.addingTimeInterval(Double(i) * 5 * 60))
+            values.append(Int16(100 + i * 5))
+        }
+
+        // GAP (15 minutes)
+        let gapStart = now.addingTimeInterval(Double(10) * 5 * 60 + 15 * 60)
+
+        // Recent block (too small -> fallback applies only here)
+        for i in 0 ..< 3 {
+            dates.append(gapStart.addingTimeInterval(Double(i) * 5 * 60))
+            values.append(Int16(200 + i * 5))
+        }
+
         await createGlucoseSequence(values: values, dates: dates, isManual: false)
 
-        // WHEN
         await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
 
-        // THEN
         let ascending = try await fetchAndSortGlucose()
-        #expect(ascending.count == 6)
+        #expect(ascending.count == values.count)
 
-        let smoothedValues = ascending
-            .filter { !$0.isManual }
-            .compactMap { $0.smoothedGlucose?.decimalValue }
-            .filter { $0 > 0 }
+        // Split into:
+        // - older block (before gap)
+        // - recent block (after gap)
+        let olderBlock = ascending.prefix(10)
+        let recentBlock = ascending.suffix(3)
 
-        #expect(
-            smoothedValues.count == 6,
-            "Fallback path should fill smoothedGlucose for all CGM entries when the gap reduces the window below minimum size."
-        )
+        // --- ASSERT 1: Older values should NOT be overwritten ---
+        for (index, obj) in olderBlock.enumerated() {
+            #expect(
+                obj.smoothedGlucose == nil,
+                "Older value at index \(index) should remain untouched (no fallback overwrite)."
+            )
+        }
+
+        // --- ASSERT 2: Recent values should be filled by fallback ---
+        for (index, obj) in recentBlock.enumerated() {
+            guard let smoothed = obj.smoothedGlucose?.decimalValue else {
+                #expect(false, "Recent value at index \(index) should have smoothedGlucose set.")
+                continue
+            }
 
-        for (index, smoothed) in smoothedValues.enumerated() {
             #expect(
                 smoothed >= 39,
-                "Fallback smoothed glucose must be clamped to >= 39, got \(smoothed) at index \(index)."
+                "Fallback smoothed glucose must be clamped to >= 39, got \(smoothed)."
             )
+
             #expect(
                 smoothed == smoothed.rounded(toPlaces: 0),
-                "Fallback smoothed glucose must be rounded to an integer, got \(smoothed) at index \(index)."
+                "Fallback smoothed glucose must be rounded to integer, got \(smoothed)."
             )
         }
     }