ソースを参照

Merge branch 'dev' of github.com:nightscout/Trio into patch/0.8.1-with-dev-removed-old-omni-submodules

Deniz Cengiz 1 日 前
コミット
ddd7a6556b

+ 1 - 1
.github/CODEOWNERS

@@ -1 +1 @@
-*    @dnzxy @bjornoleh @MikePlante1 @AndreasStokholm @Sjoerd-Bo3 @t1dude @marv-out
+*    @dnzxy @bjornoleh @MikePlante1 @AndreasStokholm @Sjoerd-Bo3 @t1dude @marv-out @kingst @marionbarker

+ 1 - 1
Config.xcconfig

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

+ 20 - 0
LiveActivity/Assets.xcassets/LoopGreen.colorset/Contents.json

@@ -0,0 +1,20 @@
+{
+  "colors" : [
+    {
+      "color" : {
+        "color-space" : "srgb",
+        "components" : {
+          "alpha" : "1.000",
+          "blue" : "0.592",
+          "green" : "0.812",
+          "red" : "0.435"
+        }
+      },
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

+ 5 - 0
LiveActivity/LiveActivity.swift

@@ -121,6 +121,11 @@ private extension LiveActivityAttributes.ContentState {
             overrideDate: Date().addingTimeInterval(-3600),
             overrideDuration: 120,
             overrideTarget: 150,
+            isTempTargetActive: false,
+            tempTargetName: "Temp Target",
+            tempTargetDate: Date().addingTimeInterval(-1800),
+            tempTargetDuration: 60,
+            tempTargetTarget: 120,
             widgetItems: LiveActivityAttributes.LiveActivityItem.defaultItems
         )
 

+ 27 - 3
LiveActivity/Views/LiveActivityChartView.swift

@@ -33,13 +33,13 @@ struct LiveActivityChartView: View {
         let target = isMgdL ? state.target : state.target.asMmolL
 
         let isOverrideActive = additionalState.isOverrideActive == true
+        let isTempTargetActive = additionalState.isTempTargetActive == true
 
         let calendar = Calendar.current
         let now = Date()
 
         let startDate = calendar.date(byAdding: .hour, value: isWatchOS ? -3 : -6, to: now) ?? now
-        let endDate = isOverrideActive ? (calendar.date(byAdding: .hour, value: 2, to: now) ?? now) :
-            (calendar.date(byAdding: .minute, value: isWatchOS ? 5 : 0, to: now) ?? now)
+        let endDate = calendar.date(byAdding: .minute, value: isWatchOS ? 5 : 0, to: now) ?? now
 
         // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
         let hardCodedLow = isMgdL ? Decimal(55) : 55.asMmolL
@@ -79,6 +79,10 @@ struct LiveActivityChartView: View {
                 drawActiveOverrides()
             }
 
+            if isTempTargetActive {
+                drawActiveTempTarget()
+            }
+
             drawChart(yAxisRuleMarkMin: yAxisRuleMarkMin, yAxisRuleMarkMax: yAxisRuleMarkMax)
         }
         .chartYAxis {
@@ -113,7 +117,9 @@ struct LiveActivityChartView: View {
         let duration = context.state.detailedViewState.overrideDuration
         let durationAsTimeInterval = TimeInterval((duration as NSDecimalNumber).doubleValue * 60) // return seconds
 
-        let end: Date = start.addingTimeInterval(durationAsTimeInterval)
+        let end: Date = duration == 0
+            ? Date(timeIntervalSinceNow: 7200)
+            : start.addingTimeInterval(durationAsTimeInterval)
         let target = context.state.detailedViewState.overrideTarget
 
         return RuleMark(
@@ -125,6 +131,24 @@ struct LiveActivityChartView: View {
         .lineStyle(.init(lineWidth: 8))
     }
 
+    private func drawActiveTempTarget() -> some ChartContent {
+        let start: Date = context.state.detailedViewState.tempTargetDate
+
+        let duration = context.state.detailedViewState.tempTargetDuration
+        let durationAsTimeInterval = TimeInterval((duration as NSDecimalNumber).doubleValue * 60) // return seconds
+
+        let end: Date = start.addingTimeInterval(durationAsTimeInterval)
+        let target = context.state.detailedViewState.tempTargetTarget
+
+        return RuleMark(
+            xStart: .value("Start", start, unit: .second),
+            xEnd: .value("End", end, unit: .second),
+            y: .value("Value", target)
+        )
+        .foregroundStyle(Color("LoopGreen").opacity(0.6))
+        .lineStyle(.init(lineWidth: 8))
+    }
+
     private func drawChart(yAxisRuleMarkMin _: Decimal, yAxisRuleMarkMax _: Decimal) -> some ChartContent {
         // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
         let hardCodedLow = Decimal(55)

+ 19 - 8
LiveActivity/Views/LiveActivityView.swift

@@ -62,18 +62,29 @@ struct LiveActivityView: View {
                 LiveActivityChartView(context: context, additionalState: context.state.detailedViewState)
                     .frame(maxWidth: UIScreen.main.bounds.width * 0.9)
                     .frame(height: 80)
-                    .overlay(alignment: .topTrailing) {
-                        if context.state.detailedViewState.isOverrideActive {
-                            HStack {
-                                Text("\(context.state.detailedViewState.overrideName)")
+                    .overlay(alignment: .topLeading) {
+                        HStack(spacing: 4) {
+                            if context.state.detailedViewState.isOverrideActive {
+                                Text(context.state.detailedViewState.overrideName)
                                     .font(.footnote)
                                     .fontWeight(.bold)
                                     .foregroundStyle(.white)
+                                    .padding(6)
+                                    .background {
+                                        RoundedRectangle(cornerRadius: 10)
+                                            .fill(Color.purple.opacity(colorScheme == .dark ? 0.6 : 0.8))
+                                    }
                             }
-                            .padding(6)
-                            .background {
-                                RoundedRectangle(cornerRadius: 10)
-                                    .fill(Color.purple.opacity(colorScheme == .dark ? 0.6 : 0.8))
+                            if context.state.detailedViewState.isTempTargetActive {
+                                Text(context.state.detailedViewState.tempTargetName)
+                                    .font(.footnote)
+                                    .fontWeight(.bold)
+                                    .foregroundStyle(.white)
+                                    .padding(6)
+                                    .background {
+                                        RoundedRectangle(cornerRadius: 10)
+                                            .fill(Color("LoopGreen").opacity(colorScheme == .dark ? 0.6 : 0.8))
+                                    }
                             }
                         }
                     }

+ 41 - 55
Trio Watch App Extension/WatchState.swift

@@ -62,6 +62,11 @@ import WatchConnectivity
 
     var recommendedBolus: Decimal = 0
 
+    /// Snapshots older than this are dropped at the top of the WC delegate
+    /// methods. Single source of truth for both `didReceiveMessage` and
+    /// `didReceiveUserInfo`.
+    private static let maxAcceptableMessageAgeInMinutes: TimeInterval = 15 * 60
+
     // MARK: - Debouncing and batch processing helpers
 
     /// Temporary storage for new data arriving via WatchConnectivity.
@@ -180,92 +185,73 @@ import WatchConnectivity
             await WatchLogger.shared.log("⌚️ Watch received data: \(message)")
         }
 
-        // If the message has a nested "watchState" dictionary with date as TimeInterval
-        if let watchStateDict = message[WatchMessageKeys.watchState] as? [String: Any],
-           let timestamp = watchStateDict[WatchMessageKeys.date] as? TimeInterval
-        {
-            let date = Date(timeIntervalSince1970: timestamp)
-
-            // Check if it's not older than 15 min
-            if date >= Date().addingTimeInterval(-15 * 60) {
-                Task {
-                    await WatchLogger.shared.log("⌚️ Handling watchState from \(date)")
-                }
-                processWatchMessage(message)
-            } else {
-                Task {
-                    await WatchLogger.shared.log("⌚️ Received outdated watchState data (\(date))")
-                }
-                DispatchQueue.main.async {
-                    self.showSyncingAnimation = false
-                }
-            }
-            return
-        }
-
-        // Else if the message is an "ack" at the top level
-        // e.g. { "acknowledged": true, "message": "Started Temp Target...", "date": Date(...) }
-        else if
-            let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
-            let ackMessage = message[WatchMessageKeys.message] as? String,
-            let ackCodeRaw = message[WatchMessageKeys.ackCode] as? String
+        // Ack at top level — no `watchState` wrapper, no staleness check.
+        if let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
+           let ackMessage = message[WatchMessageKeys.message] as? String,
+           let ackCodeRaw = message[WatchMessageKeys.ackCode] as? String
         {
             Task {
                 await WatchLogger.shared
                     .log("⌚️ Handling ack with message: \(ackMessage), success: \(acknowledged), ackCode: \(ackCodeRaw)")
             }
             DispatchQueue.main.async {
-                // For ack messages, we do NOT show “Syncing...”
                 self.showSyncingAnimation = false
             }
             processWatchMessage(message)
             return
+        }
 
-                    // Recommended bolus is also not part of the WatchState message, hence the extra condition here
-        } else if
-            let recommendedBolus = message[WatchMessageKeys.recommendedBolus] as? NSNumber
-        {
+        // Recommended bolus is also not part of the WatchState message.
+        if let recommendedBolus = message[WatchMessageKeys.recommendedBolus] as? NSNumber {
             Task {
                 await WatchLogger.shared.log("⌚️ Received recommended bolus: \(recommendedBolus)")
             }
-
             DispatchQueue.main.async {
                 self.recommendedBolus = recommendedBolus.decimalValue
                 self.showBolusCalculationProgress = false
             }
-
             return
-        } else {
-            Task {
-                await WatchLogger.shared.log("⌚️ Faulty data. Skipping...")
-            }
-            DispatchQueue.main.async {
-                self.showSyncingAnimation = false
-            }
         }
+
+        handleIncomingWatchStatePayload(message)
     }
 
     func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
-        guard let snapshot = WatchStateSnapshot(from: userInfo) else {
-            Task {
-                await WatchLogger.shared.log("⌚️ Invalid snapshot received", force: true)
-            }
+        handleIncomingWatchStatePayload(userInfo)
+    }
+
+    /// Shared path for watch-state payloads from either delegate method.
+    /// Enforces the freshness contract in one place so the two delivery paths
+    /// can't drift.
+    private func handleIncomingWatchStatePayload(_ dictionary: [String: Any]) {
+        guard let payload = dictionary[WatchMessageKeys.watchState] as? [String: Any],
+              let timestamp = payload[WatchMessageKeys.date] as? TimeInterval
+        else {
+            Task { await WatchLogger.shared.log("⌚️ Faulty watch state payload — skipping", force: true) }
+            DispatchQueue.main.async { self.showSyncingAnimation = false }
             return
         }
+        let date = Date(timeIntervalSince1970: timestamp)
 
-        let lastProcessed = WatchStateSnapshot.loadLatestDateFromDisk()
-
-        guard snapshot.date > lastProcessed else {
-            Task {
-                await WatchLogger.shared.log("⌚️ Ignoring outdated or duplicate WatchState snapshot", force: true)
-            }
+        // Wall-clock staleness gate. Drops the queued backlog cheaply when
+        // the watch app wakes after long disuse; without it, every payload
+        // schedules merge + UI work.
+        guard date >= Date().addingTimeInterval(-Self.maxAcceptableMessageAgeInMinutes) else {
+            Task { await WatchLogger.shared.log("⌚️ Skipping stale watch state (\(date))") }
+            DispatchQueue.main.async { self.showSyncingAnimation = false }
             return
         }
 
-        WatchStateSnapshot.saveLatestDateToDisk(snapshot.date)
+        // Monotonicity dedup.
+        let lastProcessed = WatchStateSnapshot.loadLatestDateFromDisk()
+        guard date > lastProcessed else {
+            Task { await WatchLogger.shared.log("⌚️ Skipping duplicate watch state (\(date))") }
+            return
+        }
 
+        WatchStateSnapshot.saveLatestDateToDisk(date)
         DispatchQueue.main.async {
-            self.scheduleUIUpdate(with: snapshot.payload)
+            self.scheduleUIUpdate(with: payload)
         }
     }
 

+ 4 - 23
Trio Watch App Extension/WatchStateSnapshot.swift

@@ -6,34 +6,15 @@
 //
 import Foundation
 
-struct WatchStateSnapshot {
-    let date: Date
-    let payload: [String: Any]
-
-    init?(from dictionary: [String: Any]) {
-        guard let timestamp = dictionary[WatchMessageKeys.date] as? TimeInterval,
-              let payload = dictionary[WatchMessageKeys.watchState] as? [String: Any]
-        else {
-            return nil
-        }
-
-        date = Date(timeIntervalSince1970: timestamp)
-        self.payload = payload
-    }
-
-    func toDictionary() -> [String: Any] {
-        [
-            WatchMessageKeys.date: date.timeIntervalSince1970,
-            WatchMessageKeys.watchState: payload
-        ]
-    }
+enum WatchStateSnapshot {
+    private static let storageKey = "WatchStateSnapshot.latest"
 
     static func saveLatestDateToDisk(_ date: Date) {
-        UserDefaults.standard.set(date.timeIntervalSince1970, forKey: "WatchStateSnapshot.latest")
+        UserDefaults.standard.set(date.timeIntervalSince1970, forKey: storageKey)
     }
 
     static func loadLatestDateFromDisk() -> Date {
-        let interval = UserDefaults.standard.double(forKey: "WatchStateSnapshot.latest")
+        let interval = UserDefaults.standard.double(forKey: storageKey)
         return Date(timeIntervalSince1970: interval)
     }
 }

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -431,6 +431,7 @@
 		BDB3C1192C03DD1000CEEAA1 /* UserDefaultsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB3C1182C03DD1000CEEAA1 /* UserDefaultsExtension.swift */; };
 		BDB899882C564509006F3298 /* ForecastChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB899872C564509006F3298 /* ForecastChart.swift */; };
 		BDB8998A2C565D0C006F3298 /* CarbsGlucose+helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB899892C565D0B006F3298 /* CarbsGlucose+helper.swift */; };
+		BB5227A51D9D4377A1A70BA6 /* TempTargetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8F3B0E159844FD8746DFC0 /* TempTargetData.swift */; };
 		BDBAACFA2C2D439700370AAE /* OverrideData.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBAACF92C2D439700370AAE /* OverrideData.swift */; };
 		BDC2EA452C3043B000E5BBD0 /* OverrideStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC2EA442C3043B000E5BBD0 /* OverrideStorage.swift */; };
 		BDC2EA472C3045AD00E5BBD0 /* Override.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC2EA462C3045AD00E5BBD0 /* Override.swift */; };
@@ -1287,6 +1288,7 @@
 		BDB3C1182C03DD1000CEEAA1 /* UserDefaultsExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsExtension.swift; sourceTree = "<group>"; };
 		BDB899872C564509006F3298 /* ForecastChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastChart.swift; sourceTree = "<group>"; };
 		BDB899892C565D0B006F3298 /* CarbsGlucose+helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CarbsGlucose+helper.swift"; sourceTree = "<group>"; };
+		4B8F3B0E159844FD8746DFC0 /* TempTargetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetData.swift; sourceTree = "<group>"; };
 		BDBAACF92C2D439700370AAE /* OverrideData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideData.swift; sourceTree = "<group>"; };
 		BDC2EA442C3043B000E5BBD0 /* OverrideStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideStorage.swift; sourceTree = "<group>"; };
 		BDC2EA462C3045AD00E5BBD0 /* Override.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Override.swift; sourceTree = "<group>"; };
@@ -3177,6 +3179,7 @@
 				BDF34F842C10C62E00D51995 /* GlucoseData.swift */,
 				BDF34F942C10D27300D51995 /* DeterminationData.swift */,
 				BDBAACF92C2D439700370AAE /* OverrideData.swift */,
+				4B8F3B0E159844FD8746DFC0 /* TempTargetData.swift */,
 			);
 			path = Data;
 			sourceTree = "<group>";
@@ -4793,6 +4796,7 @@
 				23888883D4EA091C88480FF2 /* TreatmentsProvider.swift in Sources */,
 				38E98A2D25F52DC400C0CED0 /* NSLocking+Extensions.swift in Sources */,
 				715120D22D3C2BB4005D9FB6 /* GlucoseNotificationsOption.swift in Sources */,
+				BB5227A51D9D4377A1A70BA6 /* TempTargetData.swift in Sources */,
 				BDBAACFA2C2D439700370AAE /* OverrideData.swift in Sources */,
 				DD9ECB682CA99F4500AA7C45 /* TrioRemoteControl.swift in Sources */,
 				38569353270B5E350002C50D /* CGMRootView.swift in Sources */,

+ 16 - 0
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -65935,6 +65935,9 @@
         }
       }
     },
+    "Carbs on Board (COB)" : {
+      "comment" : "Live Activity widget icon label for Carbs on Board (COB)"
+    },
     "Carbs On Board (COB)" : {
       "localizations" : {
         "bg" : {
@@ -127952,6 +127955,9 @@
         }
       }
     },
+    "Glucose and Trend, no Delta" : {
+      "comment" : "Live Activity widget icon label for Glucose and Trend, no Delta"
+    },
     "Glucose Calculation" : {
       "localizations" : {
         "bg" : {
@@ -131469,6 +131475,9 @@
         }
       }
     },
+    "Glucose, Trend, Delta" : {
+      "comment" : "Live Activity widget icon label for Glucose, Trend, Delta"
+    },
     "Glucose: %@" : {
       "comment" : "Glucose: %@",
       "extractionState" : "manual",
@@ -145980,6 +145989,9 @@
         }
       }
     },
+    "Insulin on Board (IOB)" : {
+      "comment" : "Live Activity widget icon label for Insulin on Board (IOB)"
+    },
     "Insulin On Board (IOB)" : {
       "localizations" : {
         "bg" : {
@@ -151784,6 +151796,9 @@
         }
       }
     },
+    "Last Updated" : {
+      "comment" : "Live Activity widget icon label for Last Updated"
+    },
     "Latest dev: %@" : {
       "localizations" : {
         "bg" : {
@@ -262919,6 +262934,7 @@
       }
     },
     "Total Daily Dose" : {
+      "comment" : "Live Activity widget icon label for Total Daily Dose",
       "localizations" : {
         "bg" : {
           "stringUnit" : {

+ 4 - 23
Trio/Sources/Models/WatchStateSnapshot.swift

@@ -6,34 +6,15 @@
 //
 import Foundation
 
-struct WatchStateSnapshot {
-    let date: Date
-    let payload: [String: Any]
-
-    init?(from dictionary: [String: Any]) {
-        guard let timestamp = dictionary[WatchMessageKeys.date] as? TimeInterval,
-              let payload = dictionary[WatchMessageKeys.watchState] as? [String: Any]
-        else {
-            return nil
-        }
-
-        date = Date(timeIntervalSince1970: timestamp)
-        self.payload = payload
-    }
-
-    func toDictionary() -> [String: Any] {
-        [
-            WatchMessageKeys.date: date.timeIntervalSince1970,
-            WatchMessageKeys.watchState: payload
-        ]
-    }
+enum WatchStateSnapshot {
+    private static let storageKey = "WatchStateSnapshot.latest"
 
     static func saveLatestDateToDisk(_ date: Date) {
-        UserDefaults.standard.set(date.timeIntervalSince1970, forKey: "WatchStateSnapshot.latest")
+        UserDefaults.standard.set(date.timeIntervalSince1970, forKey: storageKey)
     }
 
     static func loadLatestDateFromDisk() -> Date {
-        let interval = UserDefaults.standard.double(forKey: "WatchStateSnapshot.latest")
+        let interval = UserDefaults.standard.double(forKey: storageKey)
         return Date(timeIntervalSince1970: interval)
     }
 }

+ 15 - 6
Trio/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift

@@ -383,17 +383,26 @@ enum LiveActivityItem: String, CaseIterable, Identifiable {
     var displayName: String {
         switch self {
         case .currentGlucoseLarge:
-            return "Glucose and Trend, no Delta"
+            return String(
+                localized: "Glucose and Trend, no Delta",
+                comment: "Live Activity widget icon label for Glucose and Trend, no Delta"
+            )
         case .currentGlucose:
-            return "Glucose, Trend, Delta"
+            return String(
+                localized: "Glucose, Trend, Delta",
+                comment: "Live Activity widget icon label for Glucose, Trend, Delta"
+            )
         case .iob:
-            return "Insulin on Board (IOB)"
+            return String(
+                localized: "Insulin on Board (IOB)",
+                comment: "Live Activity widget icon label for Insulin on Board (IOB)"
+            )
         case .cob:
-            return "Carbs on Board (IOB)"
+            return String(localized: "Carbs on Board (COB)", comment: "Live Activity widget icon label for Carbs on Board (COB)")
         case .updatedLabel:
-            return "Last Updated"
+            return String(localized: "Last Updated", comment: "Live Activity widget icon label for Last Updated")
         case .totalDailyDose:
-            return "Total Daily Dose"
+            return String(localized: "Total Daily Dose", comment: "Live Activity widget icon label for Total Daily Dose")
         }
     }
 }

+ 32 - 33
Trio/Sources/Modules/Stat/StatStateModel+Setup/MealStatsSetup.swift

@@ -31,8 +31,8 @@ extension Stat.StateModel {
                     self.dailyMealStats = daily
                 }
 
-                // Initially calculate and cache daily averages
-                await calculateAndCacheDailyAverages()
+                // Initially calculate and cache per-day totals
+                await calculateAndCacheDailyTotals()
             } catch {
                 debug(.default, "\(DebuggingIdentifiers.failed) failed to fetch meal stats: \(error)")
             }
@@ -107,39 +107,31 @@ extension Stat.StateModel {
         }
     }
 
-    /// Calculates and caches the daily averages of macronutrients
+    /// Caches per-day macro totals keyed by `startOfDay`, for fast range
+    /// lookups in `calculateAveragesForDateRange`.
     ///
-    /// This function:
-    /// 1. Groups meal statistics by day
-    /// 2. Calculates average carbs, fat and protein for each day
-    /// 3. Caches the results for later use
+    /// `dailyMealStats` already has one entry per day (built that way by
+    /// `fetchMealStats`'s `dailyGrouped`), so re-grouping and dividing by
+    /// `count == 1` was a no-op.
+    /// The `uniquingKeysWith:` merge is defensive against any future call
+    /// site that constructs `MealStats` with mid-day timestamps for the
+    /// same day.
     ///
-    /// This only needs to be called once during subscribe.
-    private func calculateAndCacheDailyAverages() async {
+    /// Only needs to be called once during subscribe.
+    private func calculateAndCacheDailyTotals() async {
         let calendar = Calendar.current
 
-        // Calculate averages in context
-        let dailyAverages = await mealTaskContext.perform { [dailyMealStats] in
-            // Group by days
-            let groupedByDay = Dictionary(grouping: dailyMealStats) { stat in
-                calendar.startOfDay(for: stat.date)
-            }
-
-            // Calculate averages for each day
-            var averages: [Date: (Double, Double, Double)] = [:]
-            for (day, stats) in groupedByDay {
-                let total = stats.reduce((0.0, 0.0, 0.0)) { acc, stat in
-                    (acc.0 + stat.carbs, acc.1 + stat.fat, acc.2 + stat.protein)
-                }
-                let count = Double(stats.count)
-                averages[day] = (total.0 / count, total.1 / count, total.2 / count)
+        let dailyTotals = Dictionary(
+            dailyMealStats.map { stat in
+                (calendar.startOfDay(for: stat.date), (stat.carbs, stat.fat, stat.protein))
+            },
+            uniquingKeysWith: { existing, new in
+                (existing.0 + new.0, existing.1 + new.1, existing.2 + new.2)
             }
-            return averages
-        }
+        )
 
-        // Update cache on main thread
         await MainActor.run {
-            self.dailyAveragesCache = dailyAverages
+            self.dailyMealTotalsCache = dailyTotals
         }
     }
 
@@ -156,17 +148,24 @@ extension Stat.StateModel {
     ///   - endDate: The end date of the range to calculate averages for
     /// - Returns: A tuple containing the average carbs, fat and protein values for the date range
     func calculateAveragesForDateRange(from startDate: Date, to endDate: Date) -> (carbs: Double, fat: Double, protein: Double) {
-        // Filter cached values to only include those within the date range
-        let relevantStats = dailyAveragesCache.filter { date, _ in
-            date >= startDate && date <= endDate
+        // Cache keys are `startOfDay` dates, so a strict `date >= startDate`
+        // wrongly excludes today's bucket when `startDate` is even a few
+        // seconds past midnight (e.g. the `.day` initial scroll position is
+        // `startOfDay(today) + 1s`, leaving `today 00:00:00` just outside the
+        // range). Compare against the day *window* — a day belongs in the
+        // range if any moment of it overlaps the range. Fixes #1181.
+        let dayLength: TimeInterval = 86400
+        let relevantStats = dailyMealTotalsCache.filter { dayStart, _ in
+            let dayEnd = dayStart.addingTimeInterval(dayLength)
+            return dayStart < endDate && dayEnd > startDate
         }
 
         // Return zeros if no data exists for the range
         guard !relevantStats.isEmpty else { return (0, 0, 0) }
 
         // Calculate total macronutrients across all days
-        let total = relevantStats.values.reduce((0.0, 0.0, 0.0)) { acc, avg in
-            (acc.0 + avg.0, acc.1 + avg.1, acc.2 + avg.2)
+        let total = relevantStats.values.reduce((0.0, 0.0, 0.0)) { acc, dayTotal in
+            (acc.0 + dayTotal.0, acc.1 + dayTotal.1, acc.2 + dayTotal.2)
         }
 
         // Calculate averages by dividing totals by number of days

+ 1 - 1
Trio/Sources/Modules/Stat/StatStateModel.swift

@@ -24,7 +24,7 @@ extension Stat {
         // Cache for Meal Stats
         var hourlyMealStats: [MealStats] = []
         var dailyMealStats: [MealStats] = []
-        var dailyAveragesCache: [Date: (carbs: Double, fat: Double, protein: Double)] = [:]
+        var dailyMealTotalsCache: [Date: (carbs: Double, fat: Double, protein: Double)] = [:]
 
         // Cache for TDD Stats
         var hourlyTDDStats: [TDDStats] = []

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

@@ -1,3 +1,4 @@
+import CoreData
 import Foundation
 
 // Fetch Data for Glucose and Determination from Core Data and map them to the Structs in order to pass them thread safe to the glucoseDidUpdate/ pushUpdate function
@@ -67,31 +68,63 @@ extension LiveActivityManager {
         }
     }
 
+    func fetchAndMapTempTarget() async throws -> TempTargetData? {
+        try await fetchAndMapLatest(
+            ofType: TempTargetStored.self,
+            predicate: .predicateForOneDayAgo,
+            key: "date",
+            propertiesToFetch: ["enabled", "name", "target", "date", "duration"]
+        ) { row in
+            TempTargetData(
+                isActive: row["enabled"] as? Bool ?? false,
+                tempTargetName: row["name"] as? String ?? "Temp Target",
+                date: row["date"] as? Date ?? Date(),
+                duration: row["duration"] as? Decimal ?? 0,
+                target: row["target"] as? Decimal ?? 0
+            )
+        }
+    }
+
     func fetchAndMapOverride() async throws -> OverrideData? {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+        try await fetchAndMapLatest(
             ofType: OverrideStored.self,
-            onContext: context,
-            predicate: NSPredicate.predicateForOneDayAgo,
+            predicate: .predicateForOneDayAgo,
             key: "date",
+            propertiesToFetch: ["enabled", "name", "target", "date", "duration"]
+        ) { row in
+            OverrideData(
+                isActive: row["enabled"] as? Bool ?? false,
+                overrideName: row["name"] as? String ?? "Override",
+                date: row["date"] as? Date ?? Date(),
+                duration: row["duration"] as? Decimal ?? 0,
+                target: row["target"] as? Decimal ?? 0
+            )
+        }
+    }
+
+    private func fetchAndMapLatest<Entity: NSManagedObject, Output>(
+        ofType type: Entity.Type,
+        predicate: NSPredicate,
+        key: String,
+        propertiesToFetch: [String],
+        map: @escaping ([String: Any]) -> Output
+    ) async throws -> Output? {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: type,
+            onContext: context,
+            predicate: predicate,
+            key: key,
             ascending: false,
             fetchLimit: 1,
-            propertiesToFetch: ["enabled", "name", "target", "date", "duration"]
+            propertiesToFetch: propertiesToFetch
         )
 
         return try await context.perform {
-            guard let overrideResults = results as? [[String: Any]] else {
+            guard let rows = results as? [[String: Any]] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
 
-            return overrideResults.first.map {
-                OverrideData(
-                    isActive: $0["enabled"] as? Bool ?? false,
-                    overrideName: $0["name"] as? String ?? "Override",
-                    date: $0["date"] as? Date ?? Date(),
-                    duration: $0["duration"] as? Decimal ?? 0,
-                    target: $0["target"] as? Decimal ?? 0
-                )
-            }
+            return rows.first.map(map)
         }
     }
 }

+ 9 - 0
Trio/Sources/Services/LiveActivity/Data/TempTargetData.swift

@@ -0,0 +1,9 @@
+import Foundation
+
+struct TempTargetData {
+    let isActive: Bool
+    let tempTargetName: String
+    let date: Date
+    let duration: Decimal
+    let target: Decimal
+}

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

@@ -43,6 +43,11 @@ struct LiveActivityAttributes: ActivityAttributes {
         let overrideDate: Date
         let overrideDuration: Decimal
         let overrideTarget: Decimal
+        let isTempTargetActive: Bool
+        let tempTargetName: String
+        let tempTargetDate: Date
+        let tempTargetDuration: Decimal
+        let tempTargetTarget: Decimal
         let widgetItems: [LiveActivityItem]
     }
 

+ 6 - 0
Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift

@@ -69,6 +69,7 @@ extension LiveActivityAttributes.ContentState {
         determination: DeterminationData?,
         iob: Decimal?,
         override: OverrideData?,
+        tempTarget: TempTargetData?,
         widgetItems: [LiveActivityAttributes.LiveActivityItem]?
     ) {
         let glucose = bg.glucose
@@ -112,6 +113,11 @@ extension LiveActivityAttributes.ContentState {
             overrideDate: override?.date ?? Date(),
             overrideDuration: override?.duration ?? 0,
             overrideTarget: override?.target ?? 0,
+            isTempTargetActive: tempTarget?.isActive ?? false,
+            tempTargetName: tempTarget?.tempTargetName ?? "Temp Target",
+            tempTargetDate: tempTarget?.date ?? Date(),
+            tempTargetDuration: tempTarget?.duration ?? 0,
+            tempTargetTarget: tempTarget?.target ?? 0,
             widgetItems: widgetItems ?? [] // set empty array here to silence compiler; this can never be nil
         )
 

+ 22 - 0
Trio/Sources/Services/LiveActivity/LiveActivityManager.swift

@@ -36,6 +36,8 @@ final class LiveActivityData: ObservableObject {
     @Published var glucoseFromPersistence: [GlucoseData]?
     /// The current override data (if any).
     @Published var override: OverrideData?
+    /// The current temp target data (if any).
+    @Published var tempTarget: TempTargetData?
     /// The widget items displayed within the live activity.
     @Published var widgetItems: [LiveActivityAttributes.LiveActivityItem]?
 }
@@ -141,6 +143,10 @@ final class LiveActivityData: ObservableObject {
             Task { await self?.loadOverrides() }
         }.store(in: &subscriptions)
 
+        coreDataPublisher?.filteredByEntityName("TempTargetStored").sink { [weak self] _ in
+            Task { await self?.loadTempTarget() }
+        }.store(in: &subscriptions)
+
         coreDataPublisher?.filteredByEntityName("GlucoseStored").sink { [weak self] _ in
             Task { await self?.loadGlucose() }
         }.store(in: &subscriptions)
@@ -179,6 +185,15 @@ final class LiveActivityData: ObservableObject {
         }
     }
 
+    /// Fetches and maps temp target data and updates the live activity content state.
+    private func loadTempTarget() async {
+        do {
+            data.tempTarget = try await fetchAndMapTempTarget()
+        } catch {
+            debug(.default, "[LiveActivityManager] \(DebuggingIdentifiers.failed) failed to fetch and map temp target: \(error)")
+        }
+    }
+
     /// Handles changes to the live activity order.
     ///
     /// Loads widget items from user defaults and triggers an update to the live activity order.
@@ -203,6 +218,7 @@ final class LiveActivityData: ObservableObject {
         Task {
             await self.loadGlucose()
             await self.loadOverrides()
+            await self.loadTempTarget()
             await self.loadDetermination()
             self.loadWidgetItems()
         }
@@ -301,6 +317,11 @@ final class LiveActivityData: ObservableObject {
                                 overrideDate: Date.now,
                                 overrideDuration: 0,
                                 overrideTarget: 0,
+                                isTempTargetActive: false,
+                                tempTargetName: "",
+                                tempTargetDate: Date.now,
+                                tempTargetDuration: 0,
+                                tempTargetTarget: 0,
                                 widgetItems: []
                             ),
                             isInitialState: true
@@ -399,6 +420,7 @@ final class LiveActivityData: ObservableObject {
             determination: determination,
             iob: data.iob,
             override: data.override,
+            tempTarget: data.tempTarget,
             widgetItems: data.widgetItems
         )
 

+ 7 - 8
Trio/Sources/Services/WatchManager/AppleWatchManager.swift

@@ -495,12 +495,12 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
             return
         }
 
-        // Skip if we already sent this state or older
-        let lastSent = WatchStateSnapshot.loadLatestDateFromDisk()
-        guard lastSent < state.date else {
-            debug(.watchManager, "🕐 Skipping push — newer or equal state already sent")
-            return
-        }
+        // Stamp the snapshot with send time. Each push gets a strictly newer
+        // `date` than the previous one, which is what the watch's monotonicity
+        // dedup relies on — including watch-requested re-pushes when no CGM
+        // tick has bumped the build-time date.
+        var state = state
+        state.date = Date()
 
         let message: [String: Any] = watchStateToDictionary(from: state)
 
@@ -510,12 +510,11 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
             session.sendMessage([WatchMessageKeys.watchState: message], replyHandler: nil) { error in
                 debug(.watchManager, "❌ Error sending watch state: \(error)")
             }
-            WatchStateSnapshot.saveLatestDateToDisk(state.date)
         } else {
-            WatchStateSnapshot.saveLatestDateToDisk(state.date)
             session.transferUserInfo([WatchMessageKeys.watchState: message])
             debug(.watchManager, "📤 Transferred new WatchState snapshot via userInfo")
         }
+        WatchStateSnapshot.saveLatestDateToDisk(state.date)
     }
 
     func sendAcknowledgment(toWatch success: Bool, message: String = "", ackCode: AcknowledgmentCode) {