Browse Source

Merge branch 'dev' into watch

Deniz Cengiz 9 tháng trước cách đây
mục cha
commit
60fc520eab
45 tập tin đã thay đổi với 2201 bổ sung569 xóa
  1. 4 3
      .github/workflows/build_trio.yml
  2. 2 2
      Config.xcconfig
  3. 1 1
      DanaKit
  4. 4 1
      Gemfile
  5. 52 46
      Gemfile.lock
  6. 1 1
      LibreTransmitter
  7. 1 1
      OmniBLE
  8. 1 1
      OmniKit
  9. 16 0
      Trio.xcodeproj/project.pbxproj
  10. 78 0
      Trio/Resources/Assets.xcassets/Colors/darkGreen.colorset/Contents.json
  11. 78 0
      Trio/Resources/Assets.xcassets/Colors/darkOrange.colorset/Contents.json
  12. 0 12
      Trio/Resources/InfoPlist.xcstrings
  13. 17 3
      Trio/Sources/APS/APSManager.swift
  14. 21 24
      Trio/Sources/APS/CGM/PluginSource.swift
  15. 40 0
      Trio/Sources/APS/FetchGlucoseManager.swift
  16. 27 6
      Trio/Sources/APS/Storage/GlucoseStorage.swift
  17. 6 1
      Trio/Sources/APS/Storage/OverrideStorage.swift
  18. 11 4
      Trio/Sources/Helpers/BackgroundTask+Helper.swift
  19. 2 0
      Trio/Sources/Helpers/Color+Extensions.swift
  20. 106 3
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  21. 32 0
      Trio/Sources/Models/BloodGlucose.swift
  22. 0 7
      Trio/Sources/Modules/Home/HomeStateModel+Setup/OverrideSetup.swift
  23. 350 0
      Trio/Sources/Modules/Stat/StatStateModel+Setup/GlucoseStatsSetup.swift
  24. 1 1
      Trio/Sources/Modules/Stat/StatStateModel+Setup/LoopChartSetup.swift
  25. 51 5
      Trio/Sources/Modules/Stat/StatStateModel.swift
  26. 43 9
      Trio/Sources/Modules/Stat/View/StatChartUtils.swift
  27. 80 20
      Trio/Sources/Modules/Stat/View/StatRootView.swift
  28. 253 0
      Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseDailyDistributionChart.swift
  29. 421 0
      Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseDailyPercentileChart.swift
  30. 14 14
      Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseDistributionChart.swift
  31. 32 32
      Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucosePercentileChart.swift
  32. 71 0
      Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucosePercentileDetailView.swift
  33. 189 121
      Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift
  34. 4 4
      Trio/Sources/Modules/Stat/View/ViewElements/Insulin/BolusStatsView.swift
  35. 2 1
      Trio/Sources/Modules/Stat/View/ViewElements/Insulin/TotalDailyDoseChart.swift
  36. 5 3
      Trio/Sources/Modules/Stat/View/ViewElements/Looping/LoopBarChartView.swift
  37. 3 2
      Trio/Sources/Modules/Stat/View/ViewElements/Meal/MealStatsView.swift
  38. 1 1
      Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift
  39. 108 222
      Trio/Sources/Services/LiveActivity/LiveActivityManager.swift
  40. 1 0
      Trio/Sources/Shortcuts/BaseIntentsRequest.swift
  41. 34 12
      Trio/Sources/Shortcuts/Bolus/BolusIntent.swift
  42. 21 0
      Trio/Sources/Shortcuts/Bolus/BolusIntentRequest.swift
  43. 4 1
      TrioTests/BolusCalculatorTests/BolusCalculatorTests.swift
  44. 4 1
      TrioTests/CoreDataTests/GlucoseStorageTests.swift
  45. 9 4
      fastlane/Fastfile

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

@@ -7,8 +7,9 @@ on:
   #push:
 
   schedule:
-    - cron: "0 8 * * 3" # Checks for updates at 08:00 UTC every Wednesday
-    - cron: "0 6 1 * *" # Builds the app on the 1st of every month at 06:00 UTC
+    # avoid starting an action at xx:00 when GitHub resources are more likely to be impacted
+    - cron: "43 8 * * 3" # Checks for updates at 08:43 UTC every Wednesday
+    - cron: "43 6 1 * *" # Builds the app on the 1st of every month at 06:43 UTC
 
 env:
   UPSTREAM_REPO: nightscout/Trio
@@ -212,7 +213,7 @@ jobs:
       | # runs if started manually, or if sync schedule is set and enabled and scheduled on the first Saturday each month, or if sync schedule is set and enabled and new commits were found
       github.event_name == 'workflow_dispatch' ||
       (needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' &&
-        (vars.SCHEDULED_BUILD != 'false' && github.event.schedule == '0 6 1 * *') ||
+        (vars.SCHEDULED_BUILD != 'false' && github.event.schedule == '43 6 1 * *') ||
         (vars.SCHEDULED_SYNC != 'false' && needs.check_latest_from_upstream.outputs.NEW_COMMITS == 'true' )
       )
     steps:

+ 2 - 2
Config.xcconfig

@@ -18,8 +18,8 @@ BUNDLE_IDENTIFIER = org.nightscout.$(DEVELOPMENT_TEAM).trio
 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.5.0
-APP_DEV_VERSION = 0.5.0.47
+APP_VERSION = 0.5.1
+APP_DEV_VERSION = 0.5.1.12
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 

+ 1 - 1
DanaKit

@@ -1 +1 @@
-Subproject commit ca240f9df3cb5dbda9ad574161c9bbf9612908b2
+Subproject commit bd52ae898a59a05421b0f860e472b1d5aeae7cdc

+ 4 - 1
Gemfile

@@ -1,3 +1,6 @@
 source "https://rubygems.org"
 
-gem "fastlane"
+# gem "fastlane"
+
+# This branch uses fastlane 2.228.0 plus pr 29596
+gem "fastlane",  git: "https://github.com/loopandlearn/fastlane.git", ref: "a670d4b092b274d58ebb5497126e47fc6a84f533"

+ 52 - 46
Gemfile.lock

@@ -1,3 +1,51 @@
+GIT
+  remote: https://github.com/loopandlearn/fastlane.git
+  revision: a670d4b092b274d58ebb5497126e47fc6a84f533
+  ref: a670d4b092b274d58ebb5497126e47fc6a84f533
+  specs:
+    fastlane (2.228.0)
+      CFPropertyList (>= 2.3, < 4.0.0)
+      addressable (>= 2.8, < 3.0.0)
+      artifactory (~> 3.0)
+      aws-sdk-s3 (~> 1.0)
+      babosa (>= 1.0.3, < 2.0.0)
+      bundler (>= 1.12.0, < 3.0.0)
+      colored (~> 1.2)
+      commander (~> 4.6)
+      dotenv (>= 2.1.1, < 3.0.0)
+      emoji_regex (>= 0.1, < 4.0)
+      excon (>= 0.71.0, < 1.0.0)
+      faraday (~> 1.0)
+      faraday-cookie_jar (~> 0.0.6)
+      faraday_middleware (~> 1.0)
+      fastimage (>= 2.1.0, < 3.0.0)
+      fastlane-sirp (>= 1.0.0)
+      gh_inspector (>= 1.1.2, < 2.0.0)
+      google-apis-androidpublisher_v3 (~> 0.3)
+      google-apis-playcustomapp_v1 (~> 0.1)
+      google-cloud-env (>= 1.6.0, < 2.0.0)
+      google-cloud-storage (~> 1.31)
+      highline (~> 2.0)
+      http-cookie (~> 1.0.5)
+      json (< 3.0.0)
+      jwt (>= 2.1.0, < 3)
+      mini_magick (>= 4.9.4, < 5.0.0)
+      multipart-post (>= 2.0.0, < 3.0.0)
+      naturally (~> 2.2)
+      optparse (>= 0.1.1, < 1.0.0)
+      plist (>= 3.1.0, < 4.0.0)
+      rubyzip (>= 2.0.0, < 3.0.0)
+      security (= 0.1.5)
+      simctl (~> 1.6.3)
+      terminal-notifier (>= 2.0.0, < 3.0.0)
+      terminal-table (~> 3)
+      tty-screen (>= 0.6.3, < 1.0.0)
+      tty-spinner (>= 0.8.0, < 1.0.0)
+      word_wrap (~> 1.0.0)
+      xcodeproj (>= 1.13.0, < 2.0.0)
+      xcpretty (~> 0.4.1)
+      xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
+
 GEM
   remote: https://rubygems.org/
   specs:
@@ -10,7 +58,7 @@ GEM
     artifactory (3.0.17)
     atomos (0.1.3)
     aws-eventstream (1.4.0)
-    aws-partitions (1.1115.0)
+    aws-partitions (1.1116.0)
     aws-sdk-core (3.225.2)
       aws-eventstream (~> 1, >= 1.3.0)
       aws-partitions (~> 1, >= 1.992.0)
@@ -18,7 +66,7 @@ GEM
       base64
       jmespath (~> 1, >= 1.6.1)
       logger
-    aws-sdk-kms (1.104.0)
+    aws-sdk-kms (1.105.0)
       aws-sdk-core (~> 3, >= 3.225.0)
       aws-sigv4 (~> 1.5)
     aws-sdk-s3 (1.189.1)
@@ -70,48 +118,6 @@ GEM
     faraday_middleware (1.2.1)
       faraday (~> 1.0)
     fastimage (2.4.0)
-    fastlane (2.228.0)
-      CFPropertyList (>= 2.3, < 4.0.0)
-      addressable (>= 2.8, < 3.0.0)
-      artifactory (~> 3.0)
-      aws-sdk-s3 (~> 1.0)
-      babosa (>= 1.0.3, < 2.0.0)
-      bundler (>= 1.12.0, < 3.0.0)
-      colored (~> 1.2)
-      commander (~> 4.6)
-      dotenv (>= 2.1.1, < 3.0.0)
-      emoji_regex (>= 0.1, < 4.0)
-      excon (>= 0.71.0, < 1.0.0)
-      faraday (~> 1.0)
-      faraday-cookie_jar (~> 0.0.6)
-      faraday_middleware (~> 1.0)
-      fastimage (>= 2.1.0, < 3.0.0)
-      fastlane-sirp (>= 1.0.0)
-      gh_inspector (>= 1.1.2, < 2.0.0)
-      google-apis-androidpublisher_v3 (~> 0.3)
-      google-apis-playcustomapp_v1 (~> 0.1)
-      google-cloud-env (>= 1.6.0, < 2.0.0)
-      google-cloud-storage (~> 1.31)
-      highline (~> 2.0)
-      http-cookie (~> 1.0.5)
-      json (< 3.0.0)
-      jwt (>= 2.1.0, < 3)
-      mini_magick (>= 4.9.4, < 5.0.0)
-      multipart-post (>= 2.0.0, < 3.0.0)
-      naturally (~> 2.2)
-      optparse (>= 0.1.1, < 1.0.0)
-      plist (>= 3.1.0, < 4.0.0)
-      rubyzip (>= 2.0.0, < 3.0.0)
-      security (= 0.1.5)
-      simctl (~> 1.6.3)
-      terminal-notifier (>= 2.0.0, < 3.0.0)
-      terminal-table (~> 3)
-      tty-screen (>= 0.6.3, < 1.0.0)
-      tty-spinner (>= 0.8.0, < 1.0.0)
-      word_wrap (~> 1.0.0)
-      xcodeproj (>= 1.13.0, < 2.0.0)
-      xcpretty (~> 0.4.1)
-      xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
     fastlane-sirp (1.0.0)
       sysrandom (~> 1.0)
     gh_inspector (1.1.3)
@@ -167,7 +173,7 @@ GEM
     multipart-post (2.4.1)
     mutex_m (0.3.0)
     nanaimo (0.4.0)
-    naturally (2.2.2)
+    naturally (2.3.0)
     nkf (0.2.0)
     optparse (0.6.0)
     os (1.1.4)
@@ -226,7 +232,7 @@ PLATFORMS
   x86_64-linux
 
 DEPENDENCIES
-  fastlane
+  fastlane!
 
 BUNDLED WITH
    2.6.2

+ 1 - 1
LibreTransmitter

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

+ 1 - 1
OmniBLE

@@ -1 +1 @@
-Subproject commit 6f65cbae4c8089a892911e273204edfc4cc81e9d
+Subproject commit 97fe52f1a43edad69a80fccce5fddb10cc813b3d

+ 1 - 1
OmniKit

@@ -1 +1 @@
-Subproject commit 92948a7684ec382714becc53c643a1617597bb37
+Subproject commit 12058d3d0394cd4269468513d838e570faf5853b

+ 16 - 0
Trio.xcodeproj/project.pbxproj

@@ -444,7 +444,11 @@
 		BDFF7A892D25F97D0016C40C /* TrioWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFF7A852D25F97D0016C40C /* TrioWatchApp.swift */; };
 		BDFF7A8B2D25F97D0016C40C /* Unit Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFF7A8A2D25F97D0016C40C /* Unit Tests.swift */; };
 		BF1667ADE69E4B5B111CECAE /* ManualTempBasalProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 680C4420C9A345D46D90D06C /* ManualTempBasalProvider.swift */; };
+		C21FE1E72DA59C6B007D550B /* GlucoseDailyDistributionChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21FE1E62DA59C6B007D550B /* GlucoseDailyDistributionChart.swift */; };
+		C28DD7262DBA9A9E00EC02DD /* GlucosePercentileDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C28DD7252DBA9A9E00EC02DD /* GlucosePercentileDetailView.swift */; };
+		C29E268A2DADFD2A00F87E75 /* GlucoseDailyPercentileChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = C29E26892DADFD2A00F87E75 /* GlucoseDailyPercentileChart.swift */; };
 		C2A0A42F2CE03131003B98E8 /* ConstantValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2A0A42E2CE0312C003B98E8 /* ConstantValues.swift */; };
+		C2A6D1E42DB1581D0036DB66 /* GlucoseStatsSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2A6D1E32DB1581D0036DB66 /* GlucoseStatsSetup.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 */; };
@@ -1260,7 +1264,11 @@
 		BDFF7A922D25F97D0016C40C /* TrioWatchAppExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioWatchAppExtension.swift; sourceTree = "<group>"; };
 		BF8BCB0C37DEB5EC377B9612 /* BasalProfileEditorRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BasalProfileEditorRootView.swift; sourceTree = "<group>"; };
 		C19984D62EFC0035A9E9644D /* TreatmentsProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TreatmentsProvider.swift; sourceTree = "<group>"; };
+		C21FE1E62DA59C6B007D550B /* GlucoseDailyDistributionChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseDailyDistributionChart.swift; sourceTree = "<group>"; };
+		C28DD7252DBA9A9E00EC02DD /* GlucosePercentileDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucosePercentileDetailView.swift; sourceTree = "<group>"; };
+		C29E26892DADFD2A00F87E75 /* GlucoseDailyPercentileChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseDailyPercentileChart.swift; sourceTree = "<group>"; };
 		C2A0A42E2CE0312C003B98E8 /* ConstantValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConstantValues.swift; sourceTree = "<group>"; };
+		C2A6D1E32DB1581D0036DB66 /* GlucoseStatsSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStatsSetup.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>"; };
@@ -2805,6 +2813,7 @@
 		BD249D952D42FCA800412DEB /* StatStateModel+Setup */ = {
 			isa = PBXGroup;
 			children = (
+				C2A6D1E32DB1581D0036DB66 /* GlucoseStatsSetup.swift */,
 				BD249DA02D42FD1000412DEB /* TDDSetup.swift */,
 				BD249D9C2D42FCF300412DEB /* MealStatsSetup.swift */,
 				BD249D982D42FCCA00412DEB /* BolusStatsSetup.swift */,
@@ -3470,6 +3479,9 @@
 		DDCAE97A2D79F99B00B1BB51 /* Glucose */ = {
 			isa = PBXGroup;
 			children = (
+				C21FE1E62DA59C6B007D550B /* GlucoseDailyDistributionChart.swift */,
+				C29E26892DADFD2A00F87E75 /* GlucoseDailyPercentileChart.swift */,
+				C28DD7252DBA9A9E00EC02DD /* GlucosePercentileDetailView.swift */,
 				BD249D912D42FC5000412DEB /* GlucoseSectorChart.swift */,
 				BD249D8B2D42FC2500412DEB /* GlucoseDistributionChart.swift */,
 				BD249D892D42FC0E00412DEB /* GlucosePercentileChart.swift */,
@@ -4086,6 +4098,7 @@
 				DD3F1F8D2D9E0E0600DCE7B3 /* NightscoutSetupStepView.swift in Sources */,
 				F90692CF274B999A0037068D /* HealthKitDataFlow.swift in Sources */,
 				CE7CA3552A064973004BE681 /* ListStateIntent.swift in Sources */,
+				C28DD7262DBA9A9E00EC02DD /* GlucosePercentileDetailView.swift in Sources */,
 				BDF530D82B40F8AC002CAF43 /* LockScreenView.swift in Sources */,
 				195D80B72AF697B800D25097 /* DynamicSettingsDataFlow.swift in Sources */,
 				DD98ACC02D71013200C0778F /* StatChartUtils.swift in Sources */,
@@ -4170,6 +4183,7 @@
 				DD1745322C55AE6000211FAC /* TargetBehavoirStateModel.swift in Sources */,
 				38E44535274E411700EC9A94 /* Disk+Data.swift in Sources */,
 				BD8E6B232D9036F700ABF8FA /* OnboardingDataFlow.swift in Sources */,
+				C2A6D1E42DB1581D0036DB66 /* GlucoseStatsSetup.swift in Sources */,
 				3811DE3125C9D49500A708ED /* HomeProvider.swift in Sources */,
 				FE41E4D629463EE20047FD55 /* NightscoutPreferences.swift in Sources */,
 				E013D872273AC6FE0014109C /* GlucoseSimulatorSource.swift in Sources */,
@@ -4199,6 +4213,7 @@
 				BD249DA12D42FD1200412DEB /* TDDSetup.swift in Sources */,
 				CE7950262998056D00FA576E /* CGMSetupView.swift in Sources */,
 				582FAE432C05102C00D1C13F /* CoreDataError.swift in Sources */,
+				C29E268A2DADFD2A00F87E75 /* GlucoseDailyPercentileChart.swift in Sources */,
 				38A0363B25ECF07E00FCBB52 /* GlucoseStorage.swift in Sources */,
 				65070A332BFDCB83006F213F /* TidepoolStartView.swift in Sources */,
 				190EBCC629FF138000BA767D /* UserInterfaceSettingsProvider.swift in Sources */,
@@ -4243,6 +4258,7 @@
 				BD432CA12D2F4E3600D1EB79 /* WatchMessageKeys.swift in Sources */,
 				DD4C581F2D73C43D001BFF2C /* LoopStatsView.swift in Sources */,
 				58A3D53A2C96D4DE003F90FC /* AddTempTargetForm.swift in Sources */,
+				C21FE1E72DA59C6B007D550B /* GlucoseDailyDistributionChart.swift in Sources */,
 				DD1745302C55AE5300211FAC /* TargetBehaviorProvider.swift in Sources */,
 				58D08B382C8DFB6000AA37D3 /* BasalChart.swift in Sources */,
 				118DF76D2C5ECBC60067FEB7 /* OverridePresetEntity.swift in Sources */,

+ 78 - 0
Trio/Resources/Assets.xcassets/Colors/darkGreen.colorset/Contents.json

@@ -0,0 +1,78 @@
+{
+  "colors" : [
+    {
+      "color" : {
+        "color-space" : "srgb",
+        "components" : {
+          "alpha" : "1.000",
+          "blue" : "0x47",
+          "green" : "0x9F",
+          "red" : "0x2A"
+        }
+      },
+      "idiom" : "universal"
+    },
+    {
+      "appearances" : [
+        {
+          "appearance" : "luminosity",
+          "value" : "dark"
+        }
+      ],
+      "color" : {
+        "color-space" : "extended-srgb",
+        "components" : {
+          "alpha" : "1.000",
+          "blue" : "0x46",
+          "green" : "0xA7",
+          "red" : "0x26"
+        }
+      },
+      "idiom" : "universal"
+    },
+    {
+      "appearances" : [
+        {
+          "appearance" : "contrast",
+          "value" : "high"
+        }
+      ],
+      "color" : {
+        "color-space" : "extended-srgb",
+        "components" : {
+          "alpha" : "1.000",
+          "blue" : "0x30",
+          "green" : "0x6E",
+          "red" : "0x1D"
+        }
+      },
+      "idiom" : "universal"
+    },
+    {
+      "appearances" : [
+        {
+          "appearance" : "luminosity",
+          "value" : "dark"
+        },
+        {
+          "appearance" : "contrast",
+          "value" : "high"
+        }
+      ],
+      "color" : {
+        "color-space" : "extended-srgb",
+        "components" : {
+          "alpha" : "1.000",
+          "blue" : "0x49",
+          "green" : "0xAF",
+          "red" : "0x26"
+        }
+      },
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

+ 78 - 0
Trio/Resources/Assets.xcassets/Colors/darkOrange.colorset/Contents.json

@@ -0,0 +1,78 @@
+{
+  "colors" : [
+    {
+      "color" : {
+        "color-space" : "extended-srgb",
+        "components" : {
+          "alpha" : "1.000",
+          "blue" : "0x00",
+          "green" : "0x77",
+          "red" : "0xCC"
+        }
+      },
+      "idiom" : "universal"
+    },
+    {
+      "appearances" : [
+        {
+          "appearance" : "luminosity",
+          "value" : "dark"
+        }
+      ],
+      "color" : {
+        "color-space" : "extended-srgb",
+        "components" : {
+          "alpha" : "1.000",
+          "blue" : "0x08",
+          "green" : "0x7F",
+          "red" : "0xCC"
+        }
+      },
+      "idiom" : "universal"
+    },
+    {
+      "appearances" : [
+        {
+          "appearance" : "contrast",
+          "value" : "high"
+        }
+      ],
+      "color" : {
+        "color-space" : "extended-srgb",
+        "components" : {
+          "alpha" : "1.000",
+          "blue" : "0x00",
+          "green" : "0x2A",
+          "red" : "0xA1"
+        }
+      },
+      "idiom" : "universal"
+    },
+    {
+      "appearances" : [
+        {
+          "appearance" : "luminosity",
+          "value" : "dark"
+        },
+        {
+          "appearance" : "contrast",
+          "value" : "high"
+        }
+      ],
+      "color" : {
+        "color-space" : "extended-srgb",
+        "components" : {
+          "alpha" : "1.000",
+          "blue" : "0x33",
+          "green" : "0x8F",
+          "red" : "0xCC"
+        }
+      },
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

+ 0 - 12
Trio/Resources/InfoPlist.xcstrings

@@ -457,18 +457,6 @@
         }
       }
     },
-    "NSCalendarsFullAccessUsageDescription" : {
-      "comment" : "Privacy - Calendars Full Access Usage Description",
-      "extractionState" : "extracted_with_value",
-      "localizations" : {
-        "en" : {
-          "stringUnit" : {
-            "state" : "new",
-            "value" : "To create events with BG reading values, so that they can be viewed on Apple Watch and CarPlay"
-          }
-        }
-      }
-    },
     "NSCalendarsUsageDescription" : {
       "comment" : "Privacy - Calendars Usage Description",
       "extractionState" : "extracted_with_value",

+ 17 - 3
Trio/Sources/APS/APSManager.swift

@@ -423,20 +423,26 @@ final class BaseAPSManager: APSManager, Injectable {
         // Fetch glucose asynchronously
         let glucose = try await fetchGlucose(predicate: NSPredicate.predicateForOneHourAgo, fetchLimit: 6)
 
+        var invalidGlucoseError: String?
+
         // Perform the context-related checks and actions
         let isValidGlucoseData = await privateContext.perform { [weak self] in
             guard let self else { return false }
 
             guard glucose.count > 2 else {
                 debug(.apsManager, "Not enough glucose data")
-                self.processError(APSError.glucoseError(message: String(localized: "Not enough glucose data")))
+                invalidGlucoseError =
+                    String(
+                        localized: "Not enough glucose data. You need at least three glucose readings in the last six hours to run the algorithm."
+                    )
                 return false
             }
 
             let dateOfLastGlucose = glucose.first?.date
             guard dateOfLastGlucose ?? Date() >= Date().addingTimeInterval(-12.minutes.timeInterval) else {
                 debug(.apsManager, "Glucose data is stale")
-                self.processError(APSError.glucoseError(message: String(localized: "Glucose data is stale")))
+                invalidGlucoseError =
+                    String(localized: "Glucose data is stale. The most recent glucose reading is from more than 12 minutes ago.")
                 return false
             }
 
@@ -468,7 +474,15 @@ final class BaseAPSManager: APSManager, Injectable {
                 }
             }
         } catch {
-            throw APSError.apsError(message: "Error determining basal: \(error.localizedDescription)")
+            // if we have a glucose validation error we might still run
+            // determineBasal to try to get IoB and CoB updates but we
+            // know that it will fail, so the invalidGlucoseError always
+            // takes priority
+            if let invalidGlucoseError = invalidGlucoseError {
+                throw APSError.apsError(message: invalidGlucoseError)
+            } else {
+                throw APSError.apsError(message: "Error determining basal: \(error.localizedDescription)")
+            }
         }
     }
 

+ 21 - 24
Trio/Sources/APS/CGM/PluginSource.swift

@@ -15,8 +15,6 @@ final class PluginSource: GlucoseSource {
 
     var cgmHasValidSensorSession: Bool = false
 
-    private var promise: Future<[BloodGlucose], Error>.Promise?
-
     init(glucoseStorage: GlucoseStorage, glucoseManager: FetchGlucoseManager) {
         self.glucoseStorage = glucoseStorage
         self.glucoseManager = glucoseManager
@@ -34,25 +32,12 @@ final class PluginSource: GlucoseSource {
     /// - Parameter timer: An optional `DispatchTimer` (not used in the function but can be used to trigger fetch logic).
     /// - Returns: An `AnyPublisher` that emits an array of `BloodGlucose` values or an empty array if an error occurs or the timeout is reached.
     func fetch(_: DispatchTimer?) -> AnyPublisher<[BloodGlucose], Never> {
-        Publishers.Merge(
-            callBLEFetch(),
-            fetchIfNeeded()
-        )
-        .filter { !$0.isEmpty }
-        .first()
-        .timeout(60 * 5, scheduler: processQueue, options: nil, customError: nil)
-        .replaceError(with: [])
-        .eraseToAnyPublisher()
-    }
-
-    func callBLEFetch() -> AnyPublisher<[BloodGlucose], Never> {
-        Future<[BloodGlucose], Error> { [weak self] promise in
-            self?.promise = promise
-        }
-        .timeout(60 * 5, scheduler: processQueue, options: nil, customError: nil)
-        .replaceError(with: [])
-        .replaceEmpty(with: [])
-        .eraseToAnyPublisher()
+        fetchIfNeeded()
+            .filter { !$0.isEmpty }
+            .first()
+            .timeout(60 * 5, scheduler: processQueue, options: nil, customError: nil)
+            .replaceError(with: [])
+            .eraseToAnyPublisher()
     }
 
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
@@ -60,8 +45,14 @@ final class PluginSource: GlucoseSource {
             guard let self = self else { return }
             self.processQueue.async {
                 guard let cgmManager = self.cgmManager else { return }
-                cgmManager.fetchNewDataIfNeeded { result in
-                    promise(self.readCGMResult(readingResult: result))
+                cgmManager.fetchNewDataIfNeeded { _ in
+                    // Ignore values returned from fetchNewDataIfNeeded since
+                    // these come from share client and cause a race condition
+                    // that causes the promise to complete before a CGM value
+                    // has a chance to return. From looking at the code this should
+                    // only impact G6 since that is the only CGM manager that will
+                    // return data and only if share credentials are set
+                    promise(.success([]))
                 }
             }
         }
@@ -123,7 +114,13 @@ extension PluginSource: CGMManagerDelegate {
 
             dispatchPrecondition(condition: .onQueue(self.processQueue))
 
-            self.promise?(self.readCGMResult(readingResult: readingResult))
+            switch self.readCGMResult(readingResult: readingResult) {
+            case let .success(glucose):
+                self.glucoseManager?.newGlucoseFromCgmManager(newGlucose: glucose)
+            case .failure:
+                debug(.deviceManager, "CGM PLUGIN - unable to read CGM result")
+            }
+
             debug(.deviceManager, "CGM PLUGIN - Direct return done")
         }
     }

+ 40 - 0
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -11,6 +11,7 @@ protocol FetchGlucoseManager: SourceInfoProvider {
     func updateGlucoseSource(cgmGlucoseSourceType: CGMType, cgmGlucosePluginId: String, newManager: CGMManagerUI?)
     func deleteGlucoseSource() async
     func removeCalibrations()
+    func newGlucoseFromCgmManager(newGlucose: [BloodGlucose])
     var glucoseSource: GlucoseSource? { get }
     var cgmManager: CGMManagerUI? { get }
     var cgmGlucoseSourceType: CGMType { get set }
@@ -55,6 +56,9 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
 
     private let context = CoreDataStack.shared.newTaskContext()
 
+    /// Enforce mutual exclusion on calls to glucoseStoreAndHeartDecision
+    private let glucoseStoreAndHeartLock = DispatchSemaphore(value: 1)
+
     var shouldSyncToRemoteService: Bool {
         guard let cgmManager = cgmManager else {
             return true
@@ -95,6 +99,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
                 )
                 .eraseToAnyPublisher()
                 .sink { newGlucose, syncDate in
+                    self.glucoseStoreAndHeartLock.wait()
                     Task {
                         do {
                             try await self.glucoseStoreAndHeartDecision(
@@ -104,6 +109,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
                         } catch {
                             debug(.deviceManager, "Failed to store glucose: \(error)")
                         }
+                        self.glucoseStoreAndHeartLock.signal()
                     }
                 }
                 .store(in: &self.lifetime)
@@ -113,6 +119,28 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         timer.resume()
     }
 
+    /// Store new glucose readings from the CGM manager
+    ///
+    /// This function enables plugin CGM managers to send new glucose readings directly
+    /// to the FetchGlucoseManager, bypassing the Combine pipeline. By bypassing the
+    /// Combine pipeline CGM managers can send backfill glucose readings, which come
+    /// right after a new glucose reading, typically.
+    func newGlucoseFromCgmManager(newGlucose: [BloodGlucose]) {
+        glucoseStoreAndHeartLock.wait()
+        let syncDate = glucoseStorage.syncDate()
+        Task {
+            do {
+                try await glucoseStoreAndHeartDecision(
+                    syncDate: syncDate,
+                    glucose: newGlucose
+                )
+            } catch {
+                debug(.deviceManager, "Failed to store glucose from CGM manager: \(error)")
+            }
+            glucoseStoreAndHeartLock.signal()
+        }
+    }
+
     var glucoseSource: GlucoseSource?
 
     func removeCalibrations() {
@@ -256,6 +284,18 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
             return
         }
 
+        // TODO: Fix backfill logic https://github.com/nightscout/Trio/issues/737
+        /*
+         let backfillGlucose = newGlucose.filter { $0.dateString <= syncDate }
+         if backfillGlucose.isNotEmpty {
+             debug(.deviceManager, "Backfilling glucose...")
+             do {
+                 try await glucoseStorage.storeGlucose(backfillGlucose)
+             } catch {
+                 debug(.deviceManager, "Unable to backfill glucose: \(error)")
+             }
+         }*/
+
         filteredByDate = newGlucose.filter { $0.dateString > syncDate }
         filtered = glucoseStorage.filterTooFrequentGlucose(filteredByDate, at: syncDate)
 

+ 27 - 6
Trio/Sources/APS/Storage/GlucoseStorage.swift

@@ -82,25 +82,46 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         }
     }
 
+    /// filter out duplicate CGM readings
+    ///
+    /// This function will look through existing stored CGM values and filter out any new CGM values that
+    /// already exist. It does matching using dates and adds a small amount of time buffer for matching (1 second)
+    /// to account for precision loss that can happen with backfill CGM readings.
     private func filterNewGlucoseValues(_ glucose: [BloodGlucose]) -> [BloodGlucose] {
-        let datesToCheck: Set<Date?> = Set(glucose.compactMap { $0.dateString as Date? })
+        let datesToCheck = glucose.map(\.dateString).sorted()
+        guard let firstDate = datesToCheck.first.map({ $0.addingTimeInterval(-1) }),
+              let lastDate = datesToCheck.last.map({ $0.addingTimeInterval(1) })
+        else {
+            return glucose
+        }
         let fetchRequest: NSFetchRequest<NSFetchRequestResult> = GlucoseStored.fetchRequest()
         fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
-            NSPredicate(format: "date IN %@", datesToCheck),
-            NSPredicate.predicateForOneDayAgo
+            NSPredicate(format: "date >= %@", firstDate as NSDate),
+            NSPredicate(format: "date <= %@", lastDate as NSDate)
         ])
         fetchRequest.propertiesToFetch = ["date"]
         fetchRequest.resultType = .dictionaryResultType
 
-        var existingDates = Set<Date>()
+        var existingDates = [Date]()
         do {
             let results = try context.fetch(fetchRequest) as? [NSDictionary]
-            existingDates = Set(results?.compactMap({ $0["date"] as? Date }) ?? [])
+            existingDates = results?.compactMap({ $0["date"] as? Date }) ?? []
         } catch {
             debugPrint("Failed to fetch existing glucose dates: \(error)")
         }
 
-        return glucose.filter { !existingDates.contains($0.dateString) }
+        // This is an inefficient filtering algorithm, but I'm assuming that the
+        // time spans are short and that duplicates are rare, so in the common
+        // case there won't be any existing dates.
+        return glucose.filter { glucose in
+            for existingDate in existingDates {
+                let difference = abs(existingDate.timeIntervalSince(glucose.dateString))
+                if difference <= 1 {
+                    return false
+                }
+            }
+            return true
+        }
     }
 
     private func storeGlucoseInCoreData(_ glucose: [BloodGlucose]) throws {

+ 6 - 1
Trio/Sources/APS/Storage/OverrideStorage.swift

@@ -172,13 +172,18 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     /// otherwise we would edit the Preset
     @MainActor func copyRunningOverride(_ override: OverrideStored) async -> NSManagedObjectID {
         let newOverride = OverrideStored(context: viewContext)
+        newOverride.id = override.id
         newOverride.duration = override.duration
         newOverride.indefinite = override.indefinite
         newOverride.percentage = override.percentage
         newOverride.smbIsOff = override.smbIsOff
         newOverride.name = override.name
         newOverride.isPreset = false // no Preset
-        newOverride.date = override.date
+        newOverride.date = override.date?
+            .addingTimeInterval(
+                1.seconds
+                    .timeInterval
+            ) // hacky solution to show the copied override as the latest override and at the same time not modify an already running preset duration
         newOverride.enabled = override.enabled
         newOverride.target = override.target
         newOverride.advancedSettings = override.advancedSettings

+ 11 - 4
Trio/Sources/Helpers/BackgroundTask+Helper.swift

@@ -17,13 +17,20 @@ func endBackgroundTaskSafely(_ taskID: inout UIBackgroundTaskIdentifier, taskNam
 ///
 /// - Parameter name: The background task name.
 func startBackgroundTask(withName name: String) -> UIBackgroundTaskIdentifier {
-    var taskID = UIBackgroundTaskIdentifier.invalid
-
-    taskID = UIApplication.shared.beginBackgroundTask(withName: name) {
+    // Use a local copy of the taskID for the expiration handler
+    let taskID = UIApplication.shared.beginBackgroundTask(withName: name) { [taskID = UIBackgroundTaskIdentifier.invalid] in
+        // Create a new Task that takes the value of the taskID as a parameter
+        // and does not use the captured variable
         Task { @MainActor in
-            endBackgroundTaskSafely(&taskID, taskName: name)
+            // Since we can no longer change the original taskID,
+            // we simply end the Task with the given ID
+            if taskID != .invalid {
+                UIApplication.shared.endBackgroundTask(taskID)
+                debug(.default, "Background task '\(name)' ended in expiration handler.")
+            }
         }
     }
 
+    debug(.default, "Background task '\(name)' started with ID: \(taskID)")
     return taskID
 }

+ 2 - 0
Trio/Sources/Helpers/Color+Extensions.swift

@@ -72,4 +72,6 @@ extension Color {
     static let lemon = Color("Lemon")
     static let minus = Color("minus")
     static let darkGray = Color("darkGray")
+    static let darkGreen = Color("darkGreen")
+    static let darkOrange = Color("darkOrange")
 }

+ 106 - 3
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -8832,9 +8832,6 @@
         }
       }
     },
-    "%lld h" : {
-
-    },
     "%lld hr" : {
       "localizations" : {
         "bg" : {
@@ -20557,6 +20554,17 @@
         }
       }
     },
+    "A external bolus of %@ U of insulin was recorded." : {
+      "extractionState" : "stale",
+      "localizations" : {
+        "fr" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Un bolus externe de %@ U d'insuline a été enregistré."
+          }
+        }
+      }
+    },
     "A few important notes…" : {
       "localizations" : {
         "bg" : {
@@ -37409,6 +37417,9 @@
         }
       }
     },
+    "An external bolus of %@ U of insulin was recorded." : {
+
+    },
     "An unknown authentication error occurred. Please try again." : {
       "localizations" : {
         "bg" : {
@@ -39645,6 +39656,7 @@
       }
     },
     "Are you sure to bolus %@ U of insulin?" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -44162,6 +44174,9 @@
         }
       }
     },
+    "Avg" : {
+
+    },
     "Axis" : {
       "localizations" : {
         "bg" : {
@@ -56277,6 +56292,7 @@
       }
     },
     "CGM Connection Trace Chart" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -67836,6 +67852,9 @@
         }
       }
     },
+    "DayEnd" : {
+
+    },
     "days" : {
       "comment" : "Total number of days of data for HbA1c estimation, part 2/2",
       "extractionState" : "manual",
@@ -68057,6 +68076,9 @@
         }
       }
     },
+    "DayStart" : {
+
+    },
     "Deactivate Pod" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -82356,6 +82378,9 @@
         }
       }
     },
+    "Distribution (by day)" : {
+
+    },
     "Do not enable this feature until you have optimized your CR (carb ratio) setting." : {
       "localizations" : {
         "bg" : {
@@ -96097,6 +96122,16 @@
         }
       }
     },
+    "External Insulin?" : {
+      "localizations" : {
+        "fr" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Insuline externe ?"
+          }
+        }
+      }
+    },
     "External:" : {
       "localizations" : {
         "bg" : {
@@ -104847,6 +104882,7 @@
       }
     },
     "Glucose data is stale" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -104952,6 +104988,9 @@
         }
       }
     },
+    "Glucose data is stale. The most recent glucose reading is from more than 12 minutes ago." : {
+
+    },
     "Glucose data is too flat" : {
       "extractionState" : "stale",
       "localizations" : {
@@ -114799,6 +114838,16 @@
         }
       }
     },
+    "If toggled, Insulin will be added to IOB but it will not be delivered" : {
+      "localizations" : {
+        "fr" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Si cette option est activée, l'insuline sera ajoutée à l'IOB mais ne sera pas administrée."
+          }
+        }
+      }
+    },
     "If toggled, you will need to confirm before applying" : {
       "localizations" : {
         "bg" : {
@@ -128627,6 +128676,16 @@
         }
       }
     },
+    "Log external insulin bolus ${bolusQuantity} U" : {
+      "localizations" : {
+        "fr" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Enregistrer un bolus d’insuline externe ${bolusQuantity} U"
+          }
+        }
+      }
+    },
     "Log FPU" : {
       "localizations" : {
         "bg" : {
@@ -139729,6 +139788,7 @@
       }
     },
     "Meal to Hypoglycemia/Hyperglycemia Distribution Chart" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -139941,6 +140001,9 @@
         }
       }
     },
+    "Med" : {
+
+    },
     "Median" : {
       "comment" : "Median BG",
       "localizations" : {
@@ -140913,6 +140976,9 @@
         }
       }
     },
+    "Mid Limit" : {
+
+    },
     "Middleware" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -146767,6 +146833,9 @@
         }
       }
     },
+    "No glucose data available for this day" : {
+
+    },
     "No Glucose Notifications will be triggered." : {
       "localizations" : {
         "bg" : {
@@ -146873,6 +146942,9 @@
         }
       }
     },
+    "No glucose readings found." : {
+
+    },
     "No glucose readings." : {
       "localizations" : {
         "bg" : {
@@ -149603,6 +149675,7 @@
       }
     },
     "Not enough glucose data" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -149708,6 +149781,9 @@
         }
       }
     },
+    "Not enough glucose data. You need at least three glucose readings in the last six hours to run the algorithm." : {
+
+    },
     "Not looping." : {
       "localizations" : {
         "bg" : {
@@ -161486,6 +161562,9 @@
         }
       }
     },
+    "Percentile (by day)" : {
+
+    },
     "Period:" : {
       "localizations" : {
         "bg" : {
@@ -178954,6 +179033,12 @@
         }
       }
     },
+    "SelectedDate" : {
+
+    },
+    "SelectedValue" : {
+
+    },
     "Selection" : {
       "localizations" : {
         "bg" : {
@@ -194723,6 +194808,7 @@
       }
     },
     "Successful Loop" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -194828,6 +194914,9 @@
         }
       }
     },
+    "Successful Loops" : {
+
+    },
     "Suggested at" : {
       "comment" : "Headline in suggested pop up (at: at what time)",
       "extractionState" : "manual",
@@ -197183,6 +197272,12 @@
         }
       }
     },
+    "Tap a percentile or tap and hold a bar to reveal more details. Swipe to scroll through time." : {
+
+    },
+    "Tap and hold a bar in the chart to reveal more details. Swipe to scroll through time." : {
+
+    },
     "Tap and hold a bar to reveal more details." : {
       "localizations" : {
         "bg" : {
@@ -202159,6 +202254,9 @@
         }
       }
     },
+    "The external bolus cannot be larger than 3 x the pump setting max bolus (%@)." : {
+
+    },
     "The Fat and Protein Delay setting defines the time between when you log fat and protein and when the system starts delivering insulin for the Fat-Protein Unit Carb Equivalents (FPUs)." : {
       "localizations" : {
         "bg" : {
@@ -222313,6 +222411,7 @@
       }
     },
     "Trio Up-Time Chart" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -230413,7 +230512,11 @@
         }
       }
     },
+    "Very Low (<%@" : {
+
+    },
     "Very Low (<%@)" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {

+ 32 - 0
Trio/Sources/Models/BloodGlucose.swift

@@ -176,9 +176,21 @@ extension Int {
     var formattedAsMmolL: String {
         NumberFormatter.glucoseFormatter.string(from: asMmolL as NSDecimalNumber) ?? "\(asMmolL)"
     }
+
+    func formatted(for units: GlucoseUnits) -> String {
+        units == .mgdL ? description : formattedAsMmolL
+    }
+
+    func formatted(withUnits units: GlucoseUnits) -> String {
+        formatted(for: units) + " \(units.rawValue)"
+    }
 }
 
 extension Decimal {
+    func asUnit(_ unit: GlucoseUnits) -> Decimal {
+        unit == .mgdL ? self : asMmolL
+    }
+
     var asMmolL: Decimal {
         Trio.rounded(self * GlucoseUnits.exchangeRate, scale: 1, roundingMode: .plain)
     }
@@ -190,9 +202,21 @@ extension Decimal {
     var formattedAsMmolL: String {
         NumberFormatter.glucoseFormatter.string(from: asMmolL as NSDecimalNumber) ?? "\(asMmolL)"
     }
+
+    func formatted(for units: GlucoseUnits) -> String {
+        units == .mgdL ? description : formattedAsMmolL
+    }
+
+    func formatted(withUnits units: GlucoseUnits) -> String {
+        formatted(for: units) + " \(units.rawValue)"
+    }
 }
 
 extension Double {
+    func asUnit(_ units: GlucoseUnits) -> Double {
+        units == .mgdL ? self : Double(truncating: asMmolL as NSNumber)
+    }
+
     var asMmolL: Decimal {
         Trio.rounded(Decimal(self) * GlucoseUnits.exchangeRate, scale: 1, roundingMode: .plain)
     }
@@ -204,6 +228,14 @@ extension Double {
     var formattedAsMmolL: String {
         NumberFormatter.glucoseFormatter.string(from: asMmolL as NSDecimalNumber) ?? "\(asMmolL)"
     }
+
+    func formatted(for units: GlucoseUnits) -> String {
+        units == .mgdL ? description : formattedAsMmolL
+    }
+
+    func formatted(withUnits units: GlucoseUnits) -> String {
+        formatted(for: units) + " \(units.rawValue)"
+    }
 }
 
 extension NumberFormatter {

+ 0 - 7
Trio/Sources/Modules/Home/HomeStateModel+Setup/OverrideSetup.swift

@@ -39,13 +39,6 @@ extension Home.StateModel {
         overrides = objects
     }
 
-    @MainActor func calculateDuration(override: OverrideStored) -> TimeInterval {
-        guard let overrideDuration = override.duration as? Double, overrideDuration != 0 else {
-            return TimeInterval(60 * 60 * 24) // one day
-        }
-        return TimeInterval(overrideDuration * 60) // return seconds
-    }
-
     // Setup expired Overrides
     func setupOverrideRunStored() {
         Task {

+ 350 - 0
Trio/Sources/Modules/Stat/StatStateModel+Setup/GlucoseStatsSetup.swift

@@ -0,0 +1,350 @@
+import CoreData
+import Foundation
+
+/// A thread-safe value type to hold glucose data without Core Data dependencies
+struct GlucoseReading: Sendable {
+    let value: Int
+    let date: Date
+}
+
+/// Represents statistical data for daily glucose metrics by distribution ranges
+struct GlucoseDailyDistributionStats: Identifiable {
+    let id = UUID()
+    /// The date this data represents
+    let date: Date
+    /// The time-in-range type used for calculations
+    let timeInRangeType: TimeInRangeType
+    /// The original glucose readings
+    let readings: [GlucoseStored]
+    /// Percentage of glucose readings below 54 mg/dL
+    let veryLowPct: Double
+    /// Percentage of glucose readings in the [54 – lowLimit] mg/dL range
+    let lowPct: Double
+    /// Percentage of glucose readings within the tighter control range of [bottomThreshold – topThreshold] mg/dL
+    let inSmallRangePct: Double
+    /// Percentage of glucose readings within the target range of [bottomThreshold – highLimit] mg/dL
+    let inRangePct: Double
+    /// Percentage of glucose readings in the (highLimit – 250] mg/dL range
+    let highPct: Double
+    /// Percentage of glucose readings above 250 mg/dL
+    let veryHighPct: Double
+
+    init(
+        date: Date,
+        timeInRangeType: TimeInRangeType,
+        readings: [GlucoseStored] = [GlucoseStored](),
+        veryLowPct: Double = 0,
+        lowPct: Double = 0,
+        inSmallRangePct: Double = 0,
+        inRangePct: Double = 0,
+        highPct: Double = 0,
+        veryHighPct: Double = 0
+    ) {
+        self.date = date
+        self.timeInRangeType = timeInRangeType
+        self.readings = readings
+        self.veryLowPct = veryLowPct
+        self.lowPct = lowPct
+        self.inSmallRangePct = inSmallRangePct
+        self.inRangePct = inRangePct
+        self.highPct = highPct
+        self.veryHighPct = veryHighPct
+    }
+}
+
+/// Represents percentile-based statistical data for daily glucose metrics
+struct GlucoseDailyPercentileStats: Identifiable {
+    let id = UUID()
+    /// The date this data represents
+    let date: Date
+    /// The original glucose readings
+    let readings: [GlucoseStored]
+    /// Minimum glucose value
+    let minimum: Double
+    /// 10th percentile glucose value
+    let percentile10: Double
+    /// 25th percentile glucose value (lower quartile)
+    let percentile25: Double
+    /// Median (50th percentile) glucose value
+    let median: Double
+    /// 75th percentile glucose value (upper quartile)
+    let percentile75: Double
+    /// 90th percentile glucose value
+    let percentile90: Double
+    /// Maximum glucose value
+    let maximum: Double
+
+    init(
+        date: Date,
+        readings: [GlucoseStored] = [GlucoseStored](),
+        minimum: Double = 0,
+        percentile10: Double = 0,
+        percentile25: Double = 0,
+        median: Double = 0,
+        percentile75: Double = 0,
+        percentile90: Double = 0,
+        maximum: Double = 0
+    ) {
+        self.date = date
+        self.readings = readings
+        self.minimum = minimum
+        self.percentile10 = percentile10
+        self.percentile25 = percentile25
+        self.median = median
+        self.percentile75 = percentile75
+        self.percentile90 = percentile90
+        self.maximum = maximum
+    }
+}
+
+extension Stat.StateModel {
+    /// Performs setup for both percentile and distribution glucose statistics from provided IDs
+    ///
+    /// This method optimizes performance by:
+    /// 1. Computing both percentile and distribution statistics concurrently
+    /// 2. Creating lookup caches for both stat types simultaneously
+    ///
+    /// - Parameter ids: Array of NSManagedObjectIDs for glucose readings
+    func setupGlucoseStats(with ids: [NSManagedObjectID]) async {
+        // Get dates for the past 90 days
+        let dates = getDates()
+
+        // Calculate both types of statistics concurrently
+        async let percentileStats = calculateDailyPercentileStats(
+            for: dates,
+            glucoseIDs: ids
+        )
+
+        async let distributionStats = calculateDailyDistributionStats(
+            for: dates,
+            glucoseIDs: ids,
+            highLimit: highLimit,
+            timeInRangeType: timeInRangeType
+        )
+
+        let (pStats, dStats) = await (percentileStats, distributionStats)
+
+        dailyGlucosePercentileStats = pStats
+        glucosePercentileCache = Dictionary(
+            uniqueKeysWithValues: pStats.map {
+                (Calendar.current.startOfDay(for: $0.date), $0)
+            }
+        )
+
+        dailyGlucoseDistributionStats = dStats
+        glucoseDistributionCache = Dictionary(
+            uniqueKeysWithValues: dStats.map {
+                (Calendar.current.startOfDay(for: $0.date), $0)
+            }
+        )
+    }
+
+    /// Generates an array of dates for the specified number of days
+    /// - Parameter daysCount: Number of days to generate
+    /// - Returns: Array of dates starting from (today - daysCount) to today
+    func getDates() -> [Date] {
+        let calendar = Calendar.current
+        let today = calendar.startOfDay(for: Date())
+
+        return (0 ..< 90).map { dayOffset -> Date in
+            calendar.startOfDay(for: calendar.date(byAdding: .day, value: -(89 - dayOffset), to: today)!)
+        }
+    }
+
+    /// Processes glucose readings for a set of dates in a thread-safe manner
+    /// - Parameters:
+    ///   - dates: Array of dates to process data for
+    ///   - glucoseIDs: Array of NSManagedObjectIDs for glucose readings
+    /// - Returns: Array of (date, readings) tuples containing filtered readings for each date
+    private func processGlucoseReadingsForDates(
+        _ dates: [Date],
+        glucoseIDs: [NSManagedObjectID]
+    ) async -> [(date: Date, readings: [GlucoseReading])] {
+        let calendar = Calendar.current
+
+        // Handle cancellation early
+        if Task.isCancelled {
+            return []
+        }
+
+        // Extract the thread-safe glucose readings
+        let privateContext = CoreDataStack.shared.newTaskContext()
+
+        // Map into Sendable struct
+        let glucoseReadings: [GlucoseReading] = await privateContext.perform {
+            // Get NSManagedObject on private context and map into GlucoseReading struct
+            glucoseIDs.compactMap { id -> GlucoseReading? in
+                guard let reading = privateContext.object(with: id) as? GlucoseStored,
+                      let date = reading.date else { return nil }
+                return GlucoseReading(value: Int(reading.glucose), date: date)
+            }
+        }
+
+        return await withTaskGroup(of: (date: Date, readings: [GlucoseReading]).self) { group in
+            for date in dates {
+                group.addTask {
+                    let dayStart = calendar.startOfDay(for: date)
+                    let dayEnd = calendar.isDateInToday(date) ?
+                        Date.now :
+                        calendar.date(byAdding: .day, value: 1, to: dayStart)!
+
+                    let filteredReadings = glucoseReadings.filter {
+                        $0.date >= dayStart && $0.date < dayEnd
+                    }
+                    return (date: date, readings: filteredReadings)
+                }
+            }
+
+            // Collect results
+            var results: [(date: Date, readings: [GlucoseReading])] = []
+            for await result in group {
+                results.append(result)
+            }
+            return results.sorted { $0.date < $1.date }
+        }
+    }
+
+    /// Creates a GlucoseDailyDistributionStats object from thread-safe reading values
+    /// - Parameters:
+    ///   - date: Date for the day
+    ///   - readings: Array of thread-safe glucose readings
+    ///   - highLimit: Upper limit for target glucose range
+    ///   - timeInRangeType: The time-in-range type to use for calculations
+    /// - Returns: GlucoseDailyDistributionStats object with calculated statistics
+    private func createGlucoseDailyDistributionStatsFromReadings(
+        date: Date,
+        readings: [GlucoseReading],
+        highLimit: Decimal,
+        timeInRangeType: TimeInRangeType
+    ) -> GlucoseDailyDistributionStats {
+        let totalReadings = Double(readings.count)
+
+        // Count readings in each range
+        let veryHighReadings = readings.filter { $0.value > 250 }.count
+        let highReadings = readings.filter { $0.value > Int(highLimit) && $0.value <= 250 }.count
+        let inRangeReadings = readings.filter { $0.value >= timeInRangeType.bottomThreshold && $0.value <= Int(highLimit) }
+            .count
+        let inSmallRangeReadings = readings
+            .filter { $0.value >= timeInRangeType.bottomThreshold && $0.value <= timeInRangeType.topThreshold }.count
+        let lowReadings = readings.filter { $0.value < timeInRangeType.bottomThreshold && $0.value >= 54 }.count
+        let veryLowReadings = readings.filter { $0.value < 54 }.count
+
+        // Calculate percentages
+        let veryLowPct = totalReadings > 0 ? Double(veryLowReadings) / totalReadings * 100 : 0
+        let lowPct = totalReadings > 0 ? Double(lowReadings) / totalReadings * 100 : 0
+        let inSmallRangePct = totalReadings > 0 ? Double(inSmallRangeReadings) / totalReadings * 100 : 0
+        let inRangePct = totalReadings > 0 ? Double(inRangeReadings) / totalReadings * 100 : 0
+        let highPct = totalReadings > 0 ? Double(highReadings) / totalReadings * 100 : 0
+        let veryHighPct = totalReadings > 0 ? Double(veryHighReadings) / totalReadings * 100 : 0
+
+        // Create empty managed object array since we don't need the actual Core Data objects
+        let emptyStoredArray: [GlucoseStored] = []
+
+        return GlucoseDailyDistributionStats(
+            date: date,
+            timeInRangeType: timeInRangeType,
+            readings: emptyStoredArray,
+            veryLowPct: veryLowPct,
+            lowPct: lowPct,
+            inSmallRangePct: inSmallRangePct,
+            inRangePct: inRangePct,
+            highPct: highPct,
+            veryHighPct: veryHighPct
+        )
+    }
+
+    /// Creates a GlucoseDailyPercentileStats object from thread-safe reading values
+    /// - Parameters:
+    ///   - date: Date for the day
+    ///   - readings: Array of thread-safe glucose readings
+    /// - Returns: GlucoseDailyPercentileStats object with calculated statistics
+    private func createGlucoseDailyPercentileStatsFromReadings(
+        date: Date,
+        readings: [GlucoseReading]
+    ) -> GlucoseDailyPercentileStats {
+        let glucoseValues = readings.map { Double($0.value) }.sorted()
+
+        // If no data, return empty data
+        guard !glucoseValues.isEmpty else {
+            return GlucoseDailyPercentileStats(date: date)
+        }
+
+        let count = glucoseValues.count
+
+        let calculatePercentile = { (p: Double) -> Double in
+            let position = Double(count - 1) * p
+            let lower = Int(floor(position))
+            let upper = Int(ceil(position))
+
+            if lower == upper {
+                return glucoseValues[lower]
+            }
+
+            let weight = position - Double(lower)
+            return glucoseValues[lower] * (1 - weight) + glucoseValues[upper] * weight
+        }
+
+        // Calculate all percentiles concurrently
+        return GlucoseDailyPercentileStats(
+            date: date,
+            readings: [],
+            minimum: glucoseValues.first ?? 0,
+            percentile10: calculatePercentile(0.10),
+            percentile25: calculatePercentile(0.25),
+            median: calculatePercentile(0.5),
+            percentile75: calculatePercentile(0.75),
+            percentile90: calculatePercentile(0.90),
+            maximum: glucoseValues.last ?? 0
+        )
+    }
+
+    func calculateDailyDistributionStats(
+        for dates: [Date],
+        glucoseIDs: [NSManagedObjectID],
+        highLimit: Decimal,
+        timeInRangeType: TimeInRangeType
+    ) async -> [GlucoseDailyDistributionStats] {
+        // Process readings for each date
+        let processedData = await processGlucoseReadingsForDates(
+            dates,
+            glucoseIDs: glucoseIDs
+        )
+
+        // Transform into distribution stats
+        return processedData.map { date, readings in
+            if readings.isEmpty {
+                return GlucoseDailyDistributionStats(date: date, timeInRangeType: timeInRangeType)
+            } else {
+                return createGlucoseDailyDistributionStatsFromReadings(
+                    date: date,
+                    readings: readings,
+                    highLimit: highLimit,
+                    timeInRangeType: timeInRangeType
+                )
+            }
+        }
+    }
+
+    func calculateDailyPercentileStats(
+        for dates: [Date],
+        glucoseIDs: [NSManagedObjectID]
+    ) async -> [GlucoseDailyPercentileStats] {
+        // Process readings for each date
+        let processedData = await processGlucoseReadingsForDates(
+            dates,
+            glucoseIDs: glucoseIDs
+        )
+
+        // Transform into percentile stats
+        return processedData.map { date, readings in
+            if readings.isEmpty {
+                return GlucoseDailyPercentileStats(date: date)
+            } else {
+                return createGlucoseDailyPercentileStatsFromReadings(
+                    date: date,
+                    readings: readings
+                )
+            }
+        }
+    }
+}

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

@@ -39,7 +39,7 @@ enum LoopStatsDataType: String {
 
     var displayName: String {
         switch self {
-        case .successfulLoop: return String(localized: "Successful Loop")
+        case .successfulLoop: return String(localized: "Successful Loops")
         case .glucoseCount: return String(localized: "Glucose Count")
         }
     }

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

@@ -37,6 +37,13 @@ extension Stat {
         var bolusAveragesCache: [Date: (manual: Double, smb: Double, external: Double)] = [:]
         var bolusTotalsCache: [(Date, total: Double)] = []
 
+        // Cache for Glucose Daily Stats
+        var dailyGlucosePercentileStats: [GlucoseDailyPercentileStats] = []
+        var glucosePercentileCache: [Date: GlucoseDailyPercentileStats] = [:]
+        var dailyGlucoseDistributionStats: [GlucoseDailyDistributionStats] = []
+        var glucoseDistributionCache: [Date: GlucoseDailyDistributionStats] = [:]
+        var glucoseReadings: [GlucoseStored] = []
+
         // Selected Duration for Glucose Stats
         var selectedIntervalForGlucoseStats: StatsTimeIntervalWithToday = .today {
             didSet {
@@ -58,7 +65,7 @@ extension Stat {
         }
 
         // Selected Glucose Chart Type
-        var selectedGlucoseChartType: GlucoseChartType = .percentile
+        var selectedGlucoseChartType: GlucoseChartType = .percentileByTime
 
         // Selected Insulin Chart Type
         var selectedInsulinChartType: InsulinChartType = .totalDailyDose
@@ -83,6 +90,7 @@ extension Stat {
             setupBolusStats()
             setupLoopStatRecords()
             setupMealStats()
+            setupGlucoseDailyStats()
             units = settingsManager.settings.units
             eA1cDisplayUnit = settingsManager.settings.eA1cDisplayUnit
             useFPUconversion = settingsManager.settings.useFPUconversion
@@ -91,9 +99,16 @@ extension Stat {
 
         func setupGlucoseArray(for interval: StatsTimeIntervalWithToday) {
             Task {
+                // Load data for current interval (existing code)
                 let ids = await fetchGlucose(for: interval)
                 await updateGlucoseArray(with: ids)
 
+                // Also ensure we have the full dataset loaded
+                if glucoseReadings.isEmpty {
+                    let allIds = await fetchGlucose(for: .total)
+                    await updateAllGlucoseArray(with: allIds)
+                }
+
                 // Calculate hourly stats and glucose range stats asynchronously with fetched glucose IDs
                 async let hourlyStats: () = calculateHourlyStatsForGlucoseAreaChart(from: ids)
                 async let glucoseRangeStats: () = calculateGlucoseRangeStatsForStackedChart(from: ids)
@@ -101,6 +116,16 @@ extension Stat {
             }
         }
 
+        func setupGlucoseDailyStats() {
+            Task {
+                // Get glucose IDs once (using the private fetchGlucose method)
+                let allIds = await fetchGlucose(for: .total)
+
+                // Pass the IDs to the implementation in GlucoseStatsSetup.swift
+                await setupGlucoseStats(with: allIds)
+            }
+        }
+
         private func fetchGlucose(for interval: StatsTimeIntervalWithToday) async -> [NSManagedObjectID] {
             do {
                 let predicate: NSPredicate
@@ -152,6 +177,19 @@ extension Stat {
                 )
             }
         }
+
+        @MainActor private func updateAllGlucoseArray(with IDs: [NSManagedObjectID]) {
+            do {
+                let glucoseObjects = try IDs.compactMap { id in
+                    try viewContext.existingObject(with: id) as? GlucoseStored
+                }
+                glucoseReadings = glucoseObjects
+            } catch {
+                debugPrint(
+                    "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the all glucose array: \(error.localizedDescription)"
+                )
+            }
+        }
     }
 
     @Observable final class UpdateTimer {
@@ -179,16 +217,24 @@ extension Stat.StateModel {
     /// Defines the available types of glucose charts
     enum GlucoseChartType: String, CaseIterable {
         /// Ambulatory Glucose Profile showing percentile ranges
-        case percentile = "Percentile"
+        case percentileByTime = "Percentile"
         /// Time-based distribution of glucose ranges
-        case distribution = "Distribution"
+        case distributionByTime = "Distribution"
+        /// Day-based box plot of glucose percentile ranges
+        case percentileByDay = "Percentile (by day)"
+        /// Day-based distribution of glucose ranges
+        case distributionByDay = "Distribution (by day)"
 
         var displayName: String {
             switch self {
-            case .percentile:
+            case .percentileByTime:
                 return String(localized: "Percentile")
-            case .distribution:
+            case .distributionByTime:
                 return String(localized: "Distribution")
+            case .percentileByDay:
+                return String(localized: "Percentile (by day)")
+            case .distributionByDay:
+                return String(localized: "Distribution (by day)")
             }
         }
     }

+ 43 - 9
Trio/Sources/Modules/Stat/View/StatChartUtils.swift

@@ -24,8 +24,29 @@ struct StatChartUtils {
         from scrollPosition: Date,
         for selectedInterval: Stat.StateModel.StatsTimeInterval
     ) -> (start: Date, end: Date) {
-        let end = scrollPosition.addingTimeInterval(visibleDomainLength(for: selectedInterval))
-        return (scrollPosition, end)
+        let calendar = Calendar.current
+
+        if selectedInterval == .day {
+            // For day view, don't modify the scroll position
+            let end = scrollPosition.addingTimeInterval(visibleDomainLength(for: selectedInterval) - 1)
+            return (scrollPosition, end)
+        } else {
+            // For week and longer intervals, we need smart alignment
+            // Find the nearest day boundary
+            let startOfDay = calendar.startOfDay(for: scrollPosition)
+            let components = calendar.dateComponents([.hour, .minute, .second], from: scrollPosition)
+            let totalSeconds = Double(components.hour ?? 0) * 3600 + Double(components.minute ?? 0) * 60 +
+                Double(components.second ?? 0)
+
+            // Align start end to midnight
+            let alignedStart = totalSeconds > 12 * 3600 ?
+                calendar.date(byAdding: .day, value: 1, to: startOfDay)! : startOfDay
+            let intervalLength = visibleDomainLength(for: selectedInterval)
+            let end = alignedStart.addingTimeInterval(intervalLength + (2 * 3600))
+            let alignedEnd = calendar.startOfDay(for: end).addingTimeInterval(-1)
+
+            return (alignedStart, alignedEnd)
+        }
     }
 
     /// Returns the appropriate date format style based on the selected time interval.
@@ -46,7 +67,9 @@ struct StatChartUtils {
     static func alignmentComponents(for selectedInterval: Stat.StateModel.StatsTimeInterval) -> DateComponents {
         switch selectedInterval {
         case .day: return DateComponents(hour: 0)
-        case .week: return DateComponents(weekday: 2)
+        case .week:
+            let calendar = Calendar.current
+            return DateComponents(weekday: calendar.firstWeekday)
         case .month,
              .total: return DateComponents(day: 1)
         }
@@ -58,14 +81,21 @@ struct StatChartUtils {
     static func getInitialScrollPosition(for selectedInterval: Stat.StateModel.StatsTimeInterval) -> Date {
         let calendar = Calendar.current
         let now = Date()
+        let today = calendar.startOfDay(for: now)
 
+        let baseDate: Date
         switch selectedInterval {
-//        case .day: return calendar.date(byAdding: .day, value: -1, to: now)!
-        case .day: return calendar.startOfDay(for: now)
-        case .week: return calendar.date(byAdding: .day, value: -7, to: now)!
-        case .month: return calendar.date(byAdding: .month, value: -1, to: now)!
-        case .total: return calendar.date(byAdding: .month, value: -3, to: now)!
+        case .day:
+            baseDate = today
+        case .week:
+            baseDate = calendar.date(byAdding: .day, value: -6, to: today)!
+        case .month:
+            baseDate = calendar.date(byAdding: .day, value: -29, to: today)!
+        case .total:
+            baseDate = calendar.date(byAdding: .day, value: -89, to: today)!
         }
+
+        return calendar.date(byAdding: .second, value: 1, to: baseDate)!
     }
 
     /// Checks if two dates belong to the same time unit based on the selected duration.
@@ -74,7 +104,11 @@ struct StatChartUtils {
     ///   - date2: The second date.
     ///   - selectedInterval: The selected time interval for statistics.
     /// - Returns: A Boolean indicating whether the two dates are in the same time unit.
-    static func isSameTimeUnit(_ date1: Date, _ date2: Date, for selectedInterval: Stat.StateModel.StatsTimeInterval) -> Bool {
+    static func isSameTimeUnit(
+        _ date1: Date,
+        _ date2: Date,
+        for selectedInterval: Stat.StateModel.StatsTimeInterval
+    ) -> Bool {
         let calendar = Calendar.current
         switch selectedInterval {
         case .day:

+ 80 - 20
Trio/Sources/Modules/Stat/View/StatRootView.swift

@@ -17,6 +17,12 @@ extension Stat {
 
         @State var state = StateModel()
         @State private var selectedView: StateModel.StatisticViewType = .glucose
+        @State private var isGlucoseDaySelected: Bool = false
+
+        private var intervalOptions: [Stat.StateModel.StatsTimeIntervalWithToday] {
+            state.selectedGlucoseChartType == .percentileByDay || state.selectedGlucoseChartType == .distributionByDay
+                ? [.week, .month, .total] : Stat.StateModel.StatsTimeIntervalWithToday.allCases
+        }
 
         var body: some View {
             VStack {
@@ -73,10 +79,18 @@ extension Stat {
                     }
                 }
                 .pickerStyle(.menu)
+                .onChange(of: state.selectedGlucoseChartType) { _, newValue in
+                    // If switching to daily chart and day/today is selected, switch to week
+                    if newValue == .percentileByDay || newValue == .distributionByDay,
+                       state.selectedIntervalForGlucoseStats == .day || state.selectedIntervalForGlucoseStats == .today
+                    {
+                        state.selectedIntervalForGlucoseStats = .week
+                    }
+                }
             }.padding(.horizontal)
 
             Picker("Duration", selection: $state.selectedIntervalForGlucoseStats) {
-                ForEach(StateModel.StatsTimeIntervalWithToday.allCases, id: \.self) { timeInterval in
+                ForEach(intervalOptions, id: \.self) { timeInterval in
                     Text(timeInterval.displayName)
                 }
             }
@@ -90,15 +104,28 @@ extension Stat {
                 )
             } else {
                 timeInRangeCard
-                glucoseStatsCard
+
+                if !isGlucoseDaySelected && state.selectedGlucoseChartType != .percentileByDay && state
+                    .selectedGlucoseChartType != .distributionByDay
+                {
+                    glucoseStatsCard
+                }
 
                 HStack {
                     var hintText: String {
                         switch state.selectedGlucoseChartType {
-                        case .percentile:
+                        case .percentileByTime:
                             String(localized: "Tap and hold the AGP graph or Time-in-Range ring to reveal more details.")
-                        case .distribution:
+                        case .distributionByTime:
                             String(localized: "Tap and hold the Time-in-Range ring to reveal more details.")
+                        case .percentileByDay:
+                            String(
+                                localized: "Tap a percentile or tap and hold a bar to reveal more details. Swipe to scroll through time."
+                            )
+                        case .distributionByDay:
+                            String(
+                                localized: "Tap and hold a bar in the chart to reveal more details. Swipe to scroll through time."
+                            )
                         }
                     }
                     Image(systemName: "hand.draw.fill")
@@ -115,18 +142,56 @@ extension Stat {
             StatCard {
                 VStack(spacing: Constants.spacing) {
                     switch state.selectedGlucoseChartType {
-                    case .percentile:
+                    case .distributionByDay,
+                         .percentileByDay:
+                        let interval: Stat.StateModel.StatsTimeInterval = {
+                            switch state.selectedIntervalForGlucoseStats {
+                            case .month,
+                                 .total:
+                                return Stat.StateModel.StatsTimeInterval(
+                                    rawValue: state.selectedIntervalForGlucoseStats.rawValue
+                                )!
+                            default:
+                                return .week
+                            }
+                        }()
+
+                        if state.selectedGlucoseChartType == .percentileByDay {
+                            GlucoseDailyPercentileChart(
+                                glucose: state.glucoseFromPersistence,
+                                highLimit: state.highLimit,
+                                units: state.units,
+                                timeInRangeType: state.timeInRangeType,
+                                selectedInterval: interval,
+                                isDaySelected: $isGlucoseDaySelected,
+                                state: state
+                            )
+                        } else { // if state.selectedGlucoseChartType == .distributionByDay
+                            GlucoseDailyDistributionChart(
+                                glucose: state.glucoseReadings,
+                                highLimit: state.highLimit,
+                                units: state.units,
+                                timeInRangeType: state.timeInRangeType,
+                                selectedInterval: interval,
+                                eA1cDisplayUnit: state.eA1cDisplayUnit,
+                                isDaySelected: $isGlucoseDaySelected,
+                                state: state
+                            )
+                        }
+
+                    case .percentileByTime:
                         GlucosePercentileChart(
                             glucose: state.glucoseFromPersistence,
                             highLimit: state.highLimit,
-                            lowLimit: state.lowLimit,
+                            timeInRangeType: state.timeInRangeType,
                             units: state.units,
                             hourlyStats: state.hourlyStats,
                             isToday: state.selectedIntervalForGlucoseStats == .today
                         )
-                    case .distribution:
+
+                    case .distributionByTime:
                         GlucoseDistributionChart(
-                            glucose: state.glucoseFromPersistence,
+                            glucose: state.glucoseReadings,
                             highLimit: state.highLimit,
                             lowLimit: state.lowLimit,
                             units: state.units,
@@ -145,7 +210,8 @@ extension Stat {
                         highLimit: state.highLimit,
                         units: state.units,
                         glucose: state.glucoseFromPersistence,
-                        timeInRangeType: state.timeInRangeType
+                        timeInRangeType: state.timeInRangeType,
+                        showChart: true
                     )
 
                     Divider()
@@ -263,19 +329,13 @@ extension Stat {
                         loopingChartView
                         loopStats
                     }
-                case .trioUpTime:
-                    // TODO: Trio Up-Time Chart
-                    ContentUnavailableView(
-                        String(localized: "Coming soon."),
-                        systemImage: "hourglass",
-                        description: Text("Trio Up-Time Chart")
-                    )
-                case .cgmConnectionTrace:
-                    // TODO: CGM Connection Trace Chart
+                case .cgmConnectionTrace,
+                     .trioUpTime:
+                    // TODO: Trio Up-Time Chart & CGM Connection Trace Chart
                     ContentUnavailableView(
                         String(localized: "Coming soon."),
                         systemImage: "hourglass",
-                        description: Text("CGM Connection Trace Chart")
+                        description: Text(state.selectedLoopingChartType.displayName)
                     )
                 }
             }
@@ -346,7 +406,7 @@ extension Stat {
                     ContentUnavailableView(
                         String(localized: "Coming soon."),
                         systemImage: "hourglass",
-                        description: Text("Meal to Hypoglycemia/Hyperglycemia Distribution Chart")
+                        description: Text(state.selectedMealChartType.displayName)
                     )
                 }
             }

+ 253 - 0
Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseDailyDistributionChart.swift

@@ -0,0 +1,253 @@
+import Charts
+import SwiftUI
+
+struct GlucoseDailyDistributionChart: View {
+    let glucose: [GlucoseStored]
+    let highLimit: Decimal
+    let units: GlucoseUnits
+    let timeInRangeType: TimeInRangeType
+    let selectedInterval: Stat.StateModel.StatsTimeInterval
+    let eA1cDisplayUnit: EstimatedA1cDisplayUnit
+
+    @Binding var isDaySelected: Bool
+
+    // Scrolling and selection states
+    @State private var scrollPosition = Date()
+    @State private var selectedDate: Date?
+    @State private var updateTimer = Stat.UpdateTimer()
+    @State private var visibleGlucose: [GlucoseStored] = []
+
+    // State model for accessing the shared data
+    let state: Stat.StateModel
+
+    // Computes the visible date range based on the current scroll position
+    @State private var visibleDateRange: (start: Date, end: Date) = (Date(), Date())
+
+    // Gets daily distribution stats for the visible date range
+    private var visibleDailyStats: [GlucoseDailyDistributionStats] {
+        let calendar = Calendar.current
+        return state.dailyGlucoseDistributionStats.filter { stat in
+            let statDate = calendar.startOfDay(for: stat.date)
+            return statDate >= calendar.startOfDay(for: visibleDateRange.start) &&
+                statDate <= calendar.startOfDay(for: visibleDateRange.end)
+        }
+    }
+
+    private func calculateVisibleDateRange() {
+        visibleDateRange = StatChartUtils.visibleDateRange(from: scrollPosition, for: selectedInterval)
+    }
+
+    // Gets selected day stats
+    private var selectedDateStats: GlucoseDailyDistributionStats? {
+        guard let selectedDate = selectedDate else { return nil }
+        let calendar = Calendar.current
+        let startOfSelectedDate = calendar.startOfDay(for: selectedDate)
+        return state.glucoseDistributionCache[startOfSelectedDate]
+    }
+
+    private func calculateVisibleGlucose() {
+        let calendar = Calendar.current
+        visibleGlucose = glucose.filter { reading in
+            guard let date = reading.date else { return false }
+            return date >= calendar.startOfDay(for: visibleDateRange.start) &&
+                date <= calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: visibleDateRange.end))!
+        }
+    }
+
+    // Compute selected day glucose readings
+    private var selectedDateGlucose: [GlucoseStored] {
+        guard let selectedDate = selectedDate else { return [] }
+        let calendar = Calendar.current
+        let dayStart = calendar.startOfDay(for: selectedDate)
+        let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart
+
+        return glucose.filter { reading in
+            guard let date = reading.date else { return false }
+            return date >= dayStart && date < dayEnd
+        }
+    }
+
+    // Active glucose data - either selected day or visible range
+    private var activeGlucoseData: [GlucoseStored] {
+        selectedDate != nil ? selectedDateGlucose : visibleGlucose
+    }
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            chartView
+                .frame(height: 200)
+
+            // Date label with transition
+            Text(selectedDate.map { formattedDate(for: $0) } ?? StatChartUtils.formatVisibleDateRange(
+                from: visibleDateRange.start,
+                to: visibleDateRange.end,
+                for: selectedInterval
+            ))
+                .font(.subheadline)
+                .frame(maxWidth: .infinity, alignment: .center)
+                .padding(.top, 8)
+                .animation(.easeInOut, value: selectedDate)
+
+            // Single sector chart with data switching
+            GlucoseSectorChart(
+                highLimit: highLimit,
+                units: units,
+                glucose: activeGlucoseData,
+                timeInRangeType: timeInRangeType,
+                showChart: false
+            )
+            .animation(.easeInOut, value: selectedDate)
+
+            Divider().padding(.vertical, 4)
+
+            // Single metrics view with data switching
+            GlucoseMetricsView(
+                units: units,
+                eA1cDisplayUnit: eA1cDisplayUnit,
+                glucose: activeGlucoseData
+            )
+            .animation(.easeInOut, value: selectedDate)
+        }
+        .onAppear {
+            scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedInterval)
+            calculateVisibleDateRange()
+            calculateVisibleGlucose()
+        }
+        .onChange(of: scrollPosition) {
+            updateTimer.scheduleUpdate {
+                calculateVisibleDateRange()
+                calculateVisibleGlucose()
+            }
+        }
+        .onChange(of: selectedInterval) { _, _ in
+            selectedDate = nil
+            isDaySelected = false
+            scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedInterval)
+        }
+    }
+
+    /// Formatted date string for display
+    private func formattedDate(for date: Date) -> String {
+        let dateFormatter = DateFormatter()
+        dateFormatter.dateFormat = "EEEE, MMMM d, yyyy"
+        return dateFormatter.string(from: date)
+    }
+
+    /// The main chart visualization showing glucose distribution by day
+    private var chartView: some View {
+        Chart {
+            ForEach(state.dailyGlucoseDistributionStats) { day in
+                barMark(x: day, y: day.veryLowPct, rangeName: "veryLow")
+                barMark(x: day, y: day.lowPct, rangeName: "low")
+                barMark(x: day, y: day.inSmallRangePct, rangeName: "inSmallRange")
+                barMark(x: day, y: day.inRangePct - day.inSmallRangePct, rangeName: "inRange")
+                barMark(x: day, y: day.highPct, rangeName: "high")
+                barMark(x: day, y: day.veryHighPct, rangeName: "veryHigh")
+            }
+        }
+        .chartForegroundStyleScale([
+            legend("veryLow"): .purple,
+            legend("low"): .red,
+            legend("inSmallRange"): .green,
+            legend("inRange"): .darkGreen,
+            legend("high"): .loopYellow,
+            legend("veryHigh"): .orange
+        ])
+        .chartXSelection(value: $selectedDate.animation(.easeInOut))
+        .onChange(of: selectedDate) { _, newValue in
+            withAnimation(.easeInOut) {
+                isDaySelected = newValue != nil
+            }
+        }
+        .chartYScale(domain: 0 ... 100)
+        .chartXAxis {
+            AxisMarks(preset: .aligned, values: .stride(by: .day)) { value in
+                if let date = value.as(Date.self) {
+                    let calendar = Calendar.current
+
+                    switch selectedInterval {
+                    case .month:
+                        // Mark the first day of the week
+                        let weekday = calendar.component(.weekday, from: date)
+                        if weekday == calendar.firstWeekday {
+                            AxisValueLabel(format: .dateTime.day(), centered: true)
+                                .font(.footnote)
+                            AxisGridLine()
+                        }
+                    case .total:
+                        // Mark the start of the month
+                        let day = calendar.component(.day, from: date)
+                        if day == 1 {
+                            AxisValueLabel(format: .dateTime.month(.abbreviated), centered: true)
+                                .font(.footnote)
+                            AxisGridLine()
+                        }
+                    default:
+                        // Mark every day
+                        AxisValueLabel(format: .dateTime.weekday(.abbreviated), centered: true)
+                            .font(.footnote)
+                        AxisGridLine()
+                    }
+                }
+            }
+        }
+        .chartYAxis {
+            AxisMarks(position: .trailing, values: [4, 25, 50, 75, 100]) { value in
+                if let percentage = value.as(Double.self) {
+                    AxisValueLabel {
+                        Text((percentage / 100).formatted(.percent.precision(.fractionLength(0))))
+                            .font(.footnote)
+                    }
+                    AxisGridLine()
+                }
+            }
+        }
+        .chartYAxisLabel(alignment: .trailing) {
+            Text("Percentage")
+                .foregroundStyle(.primary)
+                .font(.footnote)
+                .padding(.vertical, 3)
+        }
+        .chartScrollableAxes(.horizontal)
+        .chartScrollPosition(x: $scrollPosition.animation(.easeInOut))
+        .chartScrollTargetBehavior(
+            .valueAligned(
+                matching: DateComponents(hour: 0),
+                majorAlignment: .matching(
+                    StatChartUtils.alignmentComponents(for: selectedInterval)
+                )
+            )
+        )
+        .chartXVisibleDomain(length: StatChartUtils.visibleDomainLength(for: selectedInterval))
+    }
+
+    /// Formats a short string with the glucose values of the requested range.
+    private func legend(_ rangeName: String) -> String {
+        switch rangeName {
+        case "veryLow":
+            return "<\(Decimal(54).formatted(for: units))"
+        case "low":
+            return "\(Decimal(54).formatted(for: units))-\(Decimal(timeInRangeType.bottomThreshold - 1).formatted(for: units))"
+        case "inSmallRange":
+            return "\(Decimal(timeInRangeType.bottomThreshold).formatted(for: units))-\(Decimal(timeInRangeType.topThreshold).formatted(for: units))"
+        case "inRange":
+            return "\(Decimal(timeInRangeType.topThreshold + 1).formatted(for: units))-\(highLimit.formatted(for: units))"
+        case "high":
+            return "\((highLimit + 1).formatted(for: units))-\(Decimal(250).formatted(for: units))"
+        case "veryHigh":
+            return ">\(Decimal(250).formatted(for: units))"
+        default:
+            return "error"
+        }
+    }
+
+    /// Creates a bar mark for the requested date and range
+    private func barMark(x: GlucoseDailyDistributionStats, y: Double, rangeName: String) -> some ChartContent {
+        BarMark(
+            x: .value("Date", x.date, unit: .day),
+            y: .value("Percentage", y)
+        )
+        .foregroundStyle(by: .value("Range", legend(rangeName)))
+        .opacity(selectedDate == nil || Calendar.current.isDate(selectedDate!, inSameDayAs: x.date) ? 1 : 0.3)
+    }
+}

+ 421 - 0
Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseDailyPercentileChart.swift

@@ -0,0 +1,421 @@
+import Charts
+import SwiftUI
+
+enum GlucosePercentileType: String, Identifiable {
+    case minimum = "Min"
+    case percentile10 = "10th"
+    case percentile25 = "25th"
+    case median = "Median"
+    case percentile75 = "75th"
+    case percentile90 = "90th"
+    case maximum = "Max"
+
+    var id: String { rawValue }
+
+    // Function to get the percentile value from a stats object
+    func getValue(from stats: GlucoseDailyPercentileStats) -> Double {
+        switch self {
+        case .minimum: return stats.minimum
+        case .percentile10: return stats.percentile10
+        case .percentile25: return stats.percentile25
+        case .median: return stats.median
+        case .percentile75: return stats.percentile75
+        case .percentile90: return stats.percentile90
+        case .maximum: return stats.maximum
+        }
+    }
+}
+
+struct GlucoseDailyPercentileChart: View {
+    let glucose: [GlucoseStored]
+    let highLimit: Decimal
+    let units: GlucoseUnits
+    let timeInRangeType: TimeInRangeType
+    let selectedInterval: Stat.StateModel.StatsTimeInterval
+
+    @Binding var isDaySelected: Bool
+
+    // Scrolling and selection states
+    @State private var scrollPosition = Date()
+    @State private var selectedDate: Date?
+    @State private var updateTimer = Stat.UpdateTimer()
+    @State private var visibleDailyStats: [GlucoseDailyPercentileStats] = []
+
+    // State for selected percentile
+    @State private var selectedPercentile: GlucosePercentileType?
+
+    // State model for accessing the shared calculations
+    let state: Stat.StateModel
+
+    // Computes the visible date range based on the current scroll position
+    @State private var visibleDateRange: (start: Date, end: Date) = (Date(), Date())
+
+    private func calculateVisibleDailyStats() {
+        let calendar = Calendar.current
+        visibleDailyStats = state.dailyGlucosePercentileStats.filter { stat in
+            let statDate = calendar.startOfDay(for: stat.date)
+            return statDate >= calendar.startOfDay(for: visibleDateRange.start) &&
+                statDate <= calendar.startOfDay(for: visibleDateRange.end)
+        }
+    }
+
+    private func calculateVisibleDateRange() {
+        visibleDateRange = StatChartUtils.visibleDateRange(from: scrollPosition, for: selectedInterval)
+    }
+
+    // Gets selected day stats
+    private var selectedDateStats: GlucoseDailyPercentileStats? {
+        selectedDate.flatMap { day in
+            state.glucosePercentileCache[Calendar.current.startOfDay(for: day)]
+        }
+    }
+
+    // Aggregates data from all visible days
+    private var aggregatedVisibleStats: GlucoseDailyPercentileStats? {
+        guard !visibleDailyStats.isEmpty else { return nil }
+
+        // Collect all glucose values from visible days
+        var allMinimums: [Double] = []
+        var allMaximums: [Double] = []
+        var all10thPercentiles: [Double] = []
+        var all25thPercentiles: [Double] = []
+        var allMedians: [Double] = []
+        var all75thPercentiles: [Double] = []
+        var all90thPercentiles: [Double] = []
+
+        // Collect data from all visible days
+        for stats in visibleDailyStats where stats.median > 0 {
+            allMinimums.append(stats.minimum)
+            allMaximums.append(stats.maximum)
+            all10thPercentiles.append(stats.percentile10)
+            all25thPercentiles.append(stats.percentile25)
+            allMedians.append(stats.median)
+            all75thPercentiles.append(stats.percentile75)
+            all90thPercentiles.append(stats.percentile90)
+        }
+
+        // Calculate aggregated values
+        let aggMinimum = allMinimums.min() ?? 0
+        let aggMaximum = allMaximums.max() ?? 0
+        let aggP10 = StatChartUtils.medianCalculationDouble(array: all10thPercentiles)
+        let aggP25 = StatChartUtils.medianCalculationDouble(array: all25thPercentiles)
+        let aggMedian = StatChartUtils.medianCalculationDouble(array: allMedians)
+        let aggP75 = StatChartUtils.medianCalculationDouble(array: all75thPercentiles)
+        let aggP90 = StatChartUtils.medianCalculationDouble(array: all90thPercentiles)
+
+        // Create a new stats object with the visible date range and aggregated values
+        return GlucoseDailyPercentileStats(
+            date: visibleDateRange.start,
+            readings: [], // Empty array since this is aggregated data
+            minimum: aggMinimum,
+            percentile10: aggP10,
+            percentile25: aggP25,
+            median: aggMedian,
+            percentile75: aggP75,
+            percentile90: aggP90,
+            maximum: aggMaximum
+        )
+    }
+
+    // Format a single date for display
+    private func formatDate(_ date: Date) -> String {
+        date.formatted(.dateTime.weekday(.wide).month(.wide).day().year())
+    }
+
+    // Get the appropriate detail view data
+    private var detailViewData: (data: GlucoseDailyPercentileStats, dateText: String)? {
+        if let selectedData = selectedDateStats {
+            // Case 1: Selected specific day
+            return (selectedData, selectedData.date.formatted(.dateTime.weekday(.wide).month(.wide).day().year()))
+        } else if let aggregatedData = aggregatedVisibleStats {
+            // Case 2: Using aggregated data
+            return (aggregatedData, StatChartUtils.formatVisibleDateRange(
+                from: visibleDateRange.start,
+                to: visibleDateRange.end,
+                for: selectedInterval
+            ))
+        }
+        return nil
+    }
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            boxplotChart
+                .frame(height: 300)
+
+            // Display detail view if we have data
+            if let viewData = detailViewData {
+                GlucoseDailyPercentileDetailView(
+                    dayData: viewData.data,
+                    units: units,
+                    dateRangeText: viewData.dateText,
+                    selectedPercentile: $selectedPercentile
+                )
+                .padding(.top, 4)
+            }
+        }
+        .onAppear {
+            calculateVisibleDateRange()
+            scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedInterval)
+            calculateVisibleDailyStats()
+        }
+        .onChange(of: scrollPosition) {
+            updateTimer.scheduleUpdate {
+                calculateVisibleDateRange()
+                calculateVisibleDailyStats()
+            }
+        }
+        .onChange(of: selectedInterval) { _, _ in
+            selectedDate = nil
+            selectedPercentile = nil
+            isDaySelected = false
+            scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedInterval)
+        }
+    }
+
+    // Simple boxplot chart with improved visuals - broken down into components
+    private var boxplotChart: some View {
+        Chart {
+            // First draw all the non-interactive elements
+            ForEach(state.dailyGlucosePercentileStats) { day in
+                if day.maximum > 0 { // Check if we have valid data
+                    // Add background components for each day
+                    spacerBarMark(for: day)
+                    percentileBarMark(
+                        for: day,
+                        startValue: day.minimum.asUnit(units),
+                        endValue: day.percentile10.asUnit(units),
+                        rangeName: "0-100%"
+                    )
+                    percentileBarMark(
+                        for: day,
+                        startValue: day.percentile10.asUnit(units),
+                        endValue: day.percentile25.asUnit(units),
+                        rangeName: "10-90%"
+                    )
+                    percentileBarMark(
+                        for: day,
+                        startValue: day.percentile25.asUnit(units),
+                        endValue: day.percentile75.asUnit(units),
+                        rangeName: "25-75%"
+                    )
+                    percentileBarMark(
+                        for: day,
+                        startValue: day.percentile75.asUnit(units),
+                        endValue: day.percentile90.asUnit(units),
+                        rangeName: "10-90%"
+                    )
+                    percentileBarMark(
+                        for: day,
+                        startValue: day.percentile90.asUnit(units),
+                        endValue: day.maximum.asUnit(units),
+                        rangeName: "0-100%"
+                    )
+                }
+            }
+
+            // Draw median marks - these should appear above the percentile bars but below the selected percentile
+            ForEach(state.dailyGlucosePercentileStats) { day in
+                if day.maximum > 0 {
+                    medianMark(for: day)
+                }
+            }
+
+            // Draw the selected percentile elements LAST so they're on top
+            if let selectedPercentile = selectedPercentile {
+                ForEach(state.dailyGlucosePercentileStats) { day in
+                    if day.maximum > 0 {
+                        // Line connecting points
+                        LineMark(
+                            x: .value("SelectedDate", day.date, unit: .day),
+                            y: .value("SelectedValue", selectedPercentile.getValue(from: day).asUnit(units))
+                        )
+                        .foregroundStyle(Color.purple)
+                        .lineStyle(StrokeStyle(lineWidth: selectedInterval == .total ? 1 : 2))
+                        .zIndex(200) // Set very high z-index
+
+                        // Point marks
+                        PointMark(
+                            x: .value("SelectedDate", day.date, unit: .day),
+                            y: .value("SelectedValue", selectedPercentile.getValue(from: day).asUnit(units))
+                        )
+                        .symbolSize(selectedInterval == .total ? 10 : 30)
+                        .foregroundStyle(Color.purple)
+                        .zIndex(300) // Even higher z-index for points
+                    }
+                }
+            }
+
+            // Threshold lines
+            RuleMark(
+                y: .value("Low Limit", Double(timeInRangeType.bottomThreshold).asUnit(units))
+            )
+            .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
+            .foregroundStyle(by: .value("Range", "\(timeInRangeType.bottomThreshold.formatted(withUnits: units))"))
+            .zIndex(100)
+
+            RuleMark(
+                y: .value("Mid Limit", Double(timeInRangeType.topThreshold).asUnit(units))
+            )
+            .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
+            .foregroundStyle(by: .value("Range", "\(timeInRangeType.topThreshold.formatted(withUnits: units))"))
+            .zIndex(100)
+
+            RuleMark(
+                y: .value("High Limit", Double(highLimit.asUnit(units)))
+            )
+            .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
+            .foregroundStyle(by: .value("Range", "\(highLimit.formatted(withUnits: units))"))
+            .zIndex(100)
+        }
+        .chartYAxis {
+            AxisMarks(values: .automatic) { value in
+                AxisGridLine()
+                AxisTick()
+                AxisValueLabel {
+                    if let glucoseValue = value.as(Double.self) {
+                        Text(
+                            units == .mmolL ?
+                                glucoseValue.formatted(.number.precision(.fractionLength(1))) :
+                                glucoseValue.formatted(.number.precision(.fractionLength(0)))
+                        )
+                        .font(.caption)
+                    }
+                }
+            }
+        }
+        .chartXAxis {
+            AxisMarks(preset: .aligned, values: .stride(by: .day)) { value in
+                if let date = value.as(Date.self) {
+                    let calendar = Calendar.current
+
+                    switch selectedInterval {
+                    case .month:
+                        // Mark the first day of the week
+                        let weekday = calendar.component(.weekday, from: date)
+                        if weekday == calendar.firstWeekday {
+                            AxisValueLabel(format: .dateTime.day(), centered: true)
+                                .font(.footnote)
+                            AxisGridLine()
+                        }
+                    case .total:
+                        // Mark the start of the month
+                        let day = calendar.component(.day, from: date)
+                        if day == 1 {
+                            AxisValueLabel(format: .dateTime.month(.abbreviated), centered: true)
+                                .font(.footnote)
+                            AxisGridLine()
+                        }
+                    default:
+                        // Mark every day
+                        AxisValueLabel(format: .dateTime.weekday(.abbreviated), centered: true)
+                            .font(.footnote)
+                        AxisGridLine()
+                    }
+                }
+            }
+        }
+        .chartYScale(domain: glucoseYScaleDomain())
+        .chartXSelection(value: $selectedDate.animation(.easeInOut))
+        .onChange(of: selectedDate) { _, newValue in
+            isDaySelected = newValue != nil
+            // Clear percentile selection when a day is selected
+            if newValue != nil {
+                selectedPercentile = nil
+            }
+        }
+        .chartForegroundStyleScale([
+            "0-100%": .blue.opacity(0.15),
+            "10-90%": .blue.opacity(0.3),
+            "25-75%": .blue.opacity(0.5),
+            "Median": .blue,
+            "\(timeInRangeType.bottomThreshold.formatted(withUnits: units))": .red,
+            "\(timeInRangeType.topThreshold.formatted(withUnits: units))": .mint,
+            "\(highLimit.formatted(withUnits: units))": .orange
+        ])
+        .chartScrollableAxes(.horizontal)
+        .chartScrollPosition(x: $scrollPosition)
+        .chartScrollTargetBehavior(
+            .valueAligned(
+                matching: DateComponents(hour: 0),
+                majorAlignment: .matching(
+                    StatChartUtils.alignmentComponents(for: selectedInterval)
+                )
+            )
+        )
+        .chartXVisibleDomain(length: StatChartUtils.visibleDomainLength(for: selectedInterval))
+    }
+
+    // MARK: - Chart Components
+
+    private func percentileBarMark(
+        for day: GlucoseDailyPercentileStats,
+        startValue: Double,
+        endValue: Double,
+        rangeName: String
+    ) -> some ChartContent {
+        BarMark(
+            x: .value("Day", day.date, unit: .day),
+            y: .value("Percentage", endValue - startValue)
+        )
+        .foregroundStyle(by: .value("Range", rangeName))
+        .opacity(getOpacity(for: day))
+    }
+
+    // Median mark - a horizontal line at the median point
+    private func medianMark(for day: GlucoseDailyPercentileStats) -> some ChartContent {
+        let baseDate = Calendar.current.startOfDay(for: day.date)
+        let startOffset = Int(0.15 * 24 * 60) // 15% of minutes in a day
+        let endOffset = Int(0.85 * 24 * 60) // 85% of minutes in a day
+
+        return RuleMark(
+            xStart: .value("DayStart", Calendar.current.date(byAdding: .minute, value: startOffset, to: baseDate)!),
+            xEnd: .value("DayEnd", Calendar.current.date(byAdding: .minute, value: endOffset, to: baseDate)!),
+            y: .value("Median", day.median.asUnit(units))
+        )
+        .lineStyle(StrokeStyle(lineWidth: 2))
+        .foregroundStyle(by: .value("Range", "Median"))
+        .opacity(getOpacity(for: day))
+    }
+
+    // Helper function to determine opacity based on selections
+    private func getOpacity(for day: GlucoseDailyPercentileStats) -> Double {
+        selectedDate.map { date in
+            StatChartUtils.isSameTimeUnit(day.date, date, for: .total) ? 1 : 0.3
+        } ?? 1
+    }
+
+    // Spacer box for each day
+    private func spacerBarMark(for day: GlucoseDailyPercentileStats) -> some ChartContent {
+        BarMark(
+            x: .value("Day", day.date, unit: .day),
+            y: .value("Percentage", day.minimum.asUnit(units))
+        )
+        .foregroundStyle(Color.clear)
+    }
+
+    // Calculate an appropriate Y axis domain for the chart
+    private func glucoseYScaleDomain() -> ClosedRange<Double> {
+        // Find actual min/max from data
+        if visibleDailyStats.isEmpty {
+            return 0 ... (units == .mgdL ? 250 : 14.0)
+        }
+
+        var allValues: [Double] = []
+        for day in visibleDailyStats where day.minimum > 0 {
+            allValues.append(day.minimum.asUnit(units))
+            allValues.append(day.maximum.asUnit(units))
+        }
+
+        guard !allValues.isEmpty else {
+            return 0 ... (units == .mgdL ? 250 : 14.0)
+        }
+
+        let minValue = allValues.min() ?? 0
+        let maxValue = allValues.max() ?? (units == .mgdL ? 250 : 14.0)
+
+        // Add some padding
+        let padding = units == .mgdL ? 20.0 : 1.0
+        return max(0, minValue - padding) ... maxValue + padding
+    }
+}

+ 14 - 14
Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseDistributionChart.swift

@@ -25,38 +25,38 @@ struct GlucoseDistributionChart: View {
                 }
             }
             .chartForegroundStyleScale([
-                "<54": .purple.opacity(0.7),
-                "54-\(timeInRangeType.bottomThreshold)": .red.opacity(0.7),
-                "\(timeInRangeType.bottomThreshold)-\(timeInRangeType.topThreshold)": .green,
-                "\(timeInRangeType.topThreshold)-180": .green.opacity(0.7),
-                "180-200": .yellow.opacity(0.7),
-                "200-220": .orange.opacity(0.7),
-                ">220": .orange.opacity(0.8)
+                "<54": .purple.opacity(0.8),
+                "54-\(timeInRangeType.bottomThreshold)": .red.opacity(0.8),
+                "\(timeInRangeType.bottomThreshold)-\(timeInRangeType.topThreshold)": .green.opacity(0.8),
+                "\(timeInRangeType.topThreshold)-180": .darkGreen.opacity(0.8),
+                "180-200": .yellow.opacity(0.8),
+                "200-220": .orange.opacity(0.8),
+                ">220": .darkOrange.opacity(0.8)
             ])
             .chartLegend(position: .bottom, alignment: .leading, spacing: 12) {
                 let legendItems: [(String, Color)] = [
-                    ("<\(units == .mgdL ? Decimal(54) : 54.asMmolL)", .purple.opacity(0.7)),
+                    ("<\(units == .mgdL ? Decimal(54) : 54.asMmolL)", .purple.opacity(0.8)),
                     (
                         "\(units == .mgdL ? Decimal(54) : 54.asMmolL)-\(units == .mgdL ? Decimal(timeInRangeType.bottomThreshold) : timeInRangeType.bottomThreshold.asMmolL)",
-                        .red.opacity(0.7)
+                        .red.opacity(0.8)
                     ),
                     (
                         "\(units == .mgdL ? Decimal(timeInRangeType.bottomThreshold) : timeInRangeType.bottomThreshold.asMmolL)-\(units == .mgdL ? Decimal(timeInRangeType.topThreshold) : timeInRangeType.topThreshold.asMmolL)",
-                        .green
+                        .green.opacity(0.8)
                     ),
                     (
                         "\(units == .mgdL ? Decimal(timeInRangeType.topThreshold) : timeInRangeType.topThreshold.asMmolL)-\(units == .mgdL ? Decimal(180) : 180.asMmolL)",
-                        .green.opacity(0.7)
+                        .darkGreen.opacity(0.8)
                     ),
                     (
                         "\(units == .mgdL ? Decimal(180) : 180.asMmolL)-\(units == .mgdL ? Decimal(200) : 200.asMmolL)",
-                        .yellow.opacity(0.7)
+                        .yellow.opacity(0.8)
                     ),
                     (
                         "\(units == .mgdL ? Decimal(200) : 200.asMmolL)-\(units == .mgdL ? Decimal(220) : 220.asMmolL)",
-                        .orange.opacity(0.7)
+                        .orange.opacity(0.8)
                     ),
-                    (">\(units == .mgdL ? Decimal(220) : 220.asMmolL)", .orange.opacity(0.8))
+                    (">\(units == .mgdL ? Decimal(220) : 220.asMmolL)", .darkOrange.opacity(0.8))
                 ]
 
                 let columns = [GridItem(.adaptive(minimum: 65), spacing: 4)]

+ 32 - 32
Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucosePercentileChart.swift

@@ -11,8 +11,8 @@ struct GlucosePercentileChart: View {
     let glucose: [GlucoseStored]
     /// The upper glucose limit for the chart.
     let highLimit: Decimal
-    /// The lower glucose limit for the chart.
-    let lowLimit: Decimal
+    /// TITR or TING
+    let timeInRangeType: TimeInRangeType
     /// The units used for glucose measurement (mg/dL or mmol/L).
     let units: GlucoseUnits
     /// The hourly glucose statistics.
@@ -47,28 +47,28 @@ struct GlucosePercentileChart: View {
                     // 10-90 percentile area
                     AreaMark(
                         x: .value("Hour", Calendar.current.dateForChartHour(stats.hour)),
-                        yStart: .value("10th Percentile", stats.percentile10),
-                        yEnd: .value("90th Percentile", stats.percentile90),
+                        yStart: .value("10th Percentile", stats.percentile10.asUnit(units)),
+                        yEnd: .value("90th Percentile", stats.percentile90.asUnit(units)),
                         series: .value("10-90", "10-90")
                     )
-                    .foregroundStyle(by: .value("Series", "10-90"))
+                    .foregroundStyle(by: .value("Series", "10-90%"))
                     .opacity(stats.median > 0 ? 0.3 : 0)
 
                     // 25-75 percentile area
                     AreaMark(
                         x: .value("Hour", Calendar.current.dateForChartHour(stats.hour)),
-                        yStart: .value("25th Percentile", stats.percentile25),
-                        yEnd: .value("75th Percentile", stats.percentile75),
+                        yStart: .value("25th Percentile", stats.percentile25.asUnit(units)),
+                        yEnd: .value("75th Percentile", stats.percentile75.asUnit(units)),
                         series: .value("25-75", "25-75")
                     )
-                    .foregroundStyle(by: .value("Series", "25-75"))
+                    .foregroundStyle(by: .value("Series", "25-75%"))
                     .opacity(stats.median > 0 ? 0.5 : 0)
 
                     // Median line
                     if stats.median > 0 {
                         LineMark(
                             x: .value("Hour", Calendar.current.dateForChartHour(stats.hour)),
-                            y: .value("Median", stats.median),
+                            y: .value("Median", stats.median.asUnit(units)),
                             series: .value("Median", "Median")
                         )
                         .lineStyle(StrokeStyle(lineWidth: 2))
@@ -77,13 +77,17 @@ struct GlucosePercentileChart: View {
                 }
 
                 // High/Low limit lines
-                RuleMark(y: .value("High Limit", Double(highLimit)))
+                RuleMark(y: .value("Low Limit", Double(timeInRangeType.bottomThreshold).asUnit(units)))
                     .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
-                    .foregroundStyle(by: .value("Series", "High"))
+                    .foregroundStyle(by: .value("Series", "\(timeInRangeType.bottomThreshold.formatted(withUnits: units))"))
 
-                RuleMark(y: .value("Low Limit", Double(lowLimit)))
+                RuleMark(y: .value("Mid Limit", Double(timeInRangeType.topThreshold).asUnit(units)))
                     .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
-                    .foregroundStyle(by: .value("Series", "Low"))
+                    .foregroundStyle(by: .value("Series", "\(timeInRangeType.topThreshold.formatted(withUnits: units))"))
+
+                RuleMark(y: .value("High Limit", Double(highLimit.asUnit(units))))
+                    .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
+                    .foregroundStyle(by: .value("Series", "\(highLimit.formatted(withUnits: units))"))
 
                 if let selectedStats, let selection {
                     RuleMark(x: .value("Selection", selection))
@@ -102,19 +106,21 @@ struct GlucosePercentileChart: View {
                 }
             }
             .chartForegroundStyleScale([
-                "10-90": Color.blue.opacity(0.3),
-                "25-75": Color.blue.opacity(0.5),
+                "10-90%": Color.blue.opacity(0.3),
+                "25-75%": Color.blue.opacity(0.5),
                 "Median": Color.blue,
-                "High": Color.orange,
-                "Low": Color.red
+                "\(timeInRangeType.bottomThreshold.formatted(withUnits: units))": Color.red,
+                "\(timeInRangeType.topThreshold.formatted(withUnits: units))": Color.mint,
+                "\(highLimit.formatted(withUnits: units))": Color.orange
             ])
             .chartLegend(position: .bottom, alignment: .leading, spacing: 12) {
                 let legendItems: [(String, Color)] = [
                     ("10-90%", Color.blue.opacity(0.3)),
-                    ("20-75%", Color.blue.opacity(0.5)),
+                    ("25-75%", Color.blue.opacity(0.5)),
                     (String(localized: "Median"), Color.blue),
-                    (String(localized: "High Threshold"), Color.orange),
-                    (String(localized: "Low Threshold"), Color.red)
+                    (String(localized: "\(timeInRangeType.bottomThreshold.formatted(withUnits: units))"), Color.red),
+                    (String(localized: "\(timeInRangeType.topThreshold.formatted(withUnits: units))"), Color.mint),
+                    (String(localized: "\(highLimit.formatted(withUnits: units))"), Color.orange)
                 ]
 
                 let columns = [GridItem(.adaptive(minimum: 100), spacing: 4)]
@@ -130,7 +136,7 @@ struct GlucosePercentileChart: View {
                     if let glucose = value.as(Double.self) {
                         AxisValueLabel {
                             Text(
-                                units == .mmolL ? glucose.asMmolL.formatted(.number.precision(.fractionLength(0))) : glucose
+                                units == .mmolL ? glucose.formatted(.number.precision(.fractionLength(1))) : glucose
                                     .formatted(.number.precision(.fractionLength(0)))
                             )
                             .font(.footnote)
@@ -183,12 +189,6 @@ struct AGPSelectionPopover: View {
         }
     }
 
-    /// A helper function to format glucose values based on the selected unit.
-    private func formattedGlucoseValue(_ value: Double) -> String {
-        units == .mmolL ? value.formattedAsMmolL :
-            value.formatted()
-    }
-
     var body: some View {
         VStack(alignment: .leading, spacing: 4) {
             Text(timeText).bold().font(.subheadline)
@@ -196,27 +196,27 @@ struct AGPSelectionPopover: View {
             Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 4) {
                 GridRow {
                     Text("Median:").bold()
-                    Text(formattedGlucoseValue(stats.median))
+                    Text(stats.median.formatted(for: units))
                     Text(units.rawValue).foregroundStyle(.secondary)
                 }
                 GridRow {
                     Text("90%:").bold()
-                    Text(formattedGlucoseValue(stats.percentile90))
+                    Text(stats.percentile90.formatted(for: units))
                     Text(units.rawValue).foregroundStyle(.secondary)
                 }
                 GridRow {
                     Text("75%:").bold()
-                    Text(formattedGlucoseValue(stats.percentile75))
+                    Text(stats.percentile75.formatted(for: units))
                     Text(units.rawValue).foregroundStyle(.secondary)
                 }
                 GridRow {
                     Text("25%:").bold()
-                    Text(formattedGlucoseValue(stats.percentile25))
+                    Text(stats.percentile25.formatted(for: units))
                     Text(units.rawValue).foregroundStyle(.secondary)
                 }
                 GridRow {
                     Text("10%:").bold()
-                    Text(formattedGlucoseValue(stats.percentile10))
+                    Text(stats.percentile10.formatted(for: units))
                     Text(units.rawValue).foregroundStyle(.secondary)
                 }
             }.font(.headline)

+ 71 - 0
Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucosePercentileDetailView.swift

@@ -0,0 +1,71 @@
+import SwiftUI
+
+struct GlucoseDailyPercentileDetailView: View {
+    let dayData: GlucoseDailyPercentileStats
+    let units: GlucoseUnits
+    let dateRangeText: String
+
+    // Binding to the parent's selectedPercentile
+    @Binding var selectedPercentile: GlucosePercentileType?
+
+    var body: some View {
+        VStack(alignment: .center, spacing: 8) {
+            Text(dateRangeText)
+                .font(.subheadline.weight(.medium))
+                .padding(.bottom, 4)
+
+            // Only show percentile details if we have valid data
+            if dayData.median > 0 {
+                // Improved percentile display
+                HStack(spacing: 0) {
+                    percentileItem(label: "Min", value: round(dayData.minimum), type: .minimum)
+                    percentileItem(label: "10%", value: round(dayData.percentile10), type: .percentile10)
+                    percentileItem(label: "25%", value: round(dayData.percentile25), type: .percentile25)
+                    percentileItem(label: "Median", value: round(dayData.median), type: .median)
+                    percentileItem(label: "75%", value: round(dayData.percentile75), type: .percentile75)
+                    percentileItem(label: "90%", value: round(dayData.percentile90), type: .percentile90)
+                    percentileItem(label: "Max", value: round(dayData.maximum), type: .maximum)
+                }
+                .padding(.vertical, 8)
+            } else {
+                Text("No glucose data available for this day")
+                    .foregroundStyle(.secondary)
+                    .padding()
+            }
+        }
+    }
+
+    /// Creates a single percentile item for the detail view
+    private func percentileItem(
+        label: String,
+        value: Double,
+        type: GlucosePercentileType
+    ) -> some View {
+        VStack(spacing: 2) {
+            Text(Decimal(value).formatted(for: units))
+                .font(.callout.monospacedDigit())
+                .foregroundStyle(type == selectedPercentile ? Color.purple : .primary)
+
+            Text(label)
+                .font(.caption2)
+                .foregroundStyle(type == selectedPercentile ? Color.purple : .secondary)
+        }
+        .frame(maxWidth: .infinity)
+        .padding(4)
+        .background(
+            RoundedRectangle(cornerRadius: 4)
+                .fill(type == selectedPercentile ? Color.purple.opacity(0.1) : Color.clear)
+                .overlay(
+                    RoundedRectangle(cornerRadius: 4)
+                        .strokeBorder(type == selectedPercentile ? Color.purple : Color.clear, lineWidth: 1)
+                )
+        )
+        .contentShape(Rectangle())
+        .onTapGesture {
+            withAnimation {
+                // Toggle selection on tap
+                selectedPercentile = (selectedPercentile == type) ? nil : type
+            }
+        }
+    }
+}

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

@@ -8,6 +8,7 @@ struct GlucoseSectorChart: View {
     let units: GlucoseUnits
     let glucose: [GlucoseStored]
     let timeInRangeType: TimeInRangeType
+    let showChart: Bool
 
     @State private var selectedCount: Int?
     @State private var selectedRange: GlucoseRange?
@@ -23,119 +24,181 @@ struct GlucoseSectorChart: View {
     }
 
     var body: some View {
-        HStack(alignment: .center, spacing: 20) {
-            // Calculate total number of glucose readings
-            let total = Decimal(glucose.count)
-            // Count readings greater than high limit (180 mg/dL)
-            let high = glucose.filter { $0.glucose > Int(highLimit) }.count
-            // Count readings between low limit (TITR: 70 mg/dL, TING 63 mg/dL) and 140 mg/dL (tight control)
-            let tight = glucose
-                .filter { $0.glucose >= timeInRangeType.bottomThreshold && $0.glucose <= timeInRangeType.topThreshold }.count
-            // Count readings between 140 and high limit (normal range)
-            let normal = glucose.filter { $0.glucose >= timeInRangeType.bottomThreshold && $0.glucose <= Int(highLimit) }.count
-            // Count readings less than low limit (low)
-            let low = glucose.filter { $0.glucose < timeInRangeType.bottomThreshold }.count
-
-            let justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
-            let sumReadings = justGlucoseArray.reduce(0, +)
-
-            let glucoseAverage = Decimal(sumReadings) / total
-            let medianGlucose = StatChartUtils.medianCalculation(array: justGlucoseArray)
-
-            let lowPercentage = Decimal(low) / total * 100
-            let tightPercentage = Decimal(tight) / total * 100
-            let inRangePercentage = Decimal(normal) / total * 100
-            let highPercentage = Decimal(high) / total * 100
-
-            VStack(alignment: .leading, spacing: 10) {
-                VStack(alignment: .leading, spacing: 5) {
-                    Text("\(formatValue(Decimal(timeInRangeType.bottomThreshold)))-\(formatValue(highLimit))").font(.subheadline)
+        if glucose.count < 1 {
+            Text("No glucose readings found.")
+        } else {
+            HStack(alignment: .center, spacing: 20) {
+                // Calculate total number of glucose readings
+                let total = Decimal(glucose.count)
+                // Count readings greater than high limit (180 mg/dL)
+                let high = glucose.filter { $0.glucose > Int(highLimit) }.count
+                // Count readings between low limit (TITR: 70 mg/dL, TING 63 mg/dL) and 140 mg/dL (tight control)
+                let tight = glucose
+                    .filter { $0.glucose >= timeInRangeType.bottomThreshold && $0.glucose <= timeInRangeType.topThreshold }.count
+                // Count readings between 140 and high limit (normal range)
+                let normal = glucose.filter { $0.glucose >= timeInRangeType.bottomThreshold && $0.glucose <= Int(highLimit) }
+                    .count
+                // Count readings less than low limit (low) (70 mg/dL if not showing chart, otherwise 70 for TITR and 63 for TING)
+                let low = glucose.filter { $0.glucose < (showChart ? Int(timeInRangeType.bottomThreshold) : 70) }.count
+                // Count readings less than moderately low limit (63 mg/dL)
+                let moderatelyLow = glucose.filter { $0.glucose < 63 }.count
+                // Count readings less than moderately high limit (220 mg/dL)
+                let moderatelyHigh = glucose.filter { $0.glucose > 220 }.count
+                // Count readings less than very low limit (54 mg/dL)
+                let veryLow = glucose.filter { $0.glucose < 54 }.count
+                // Count readings less than very high limit (250 mg/dL)
+                let veryHigh = glucose.filter { $0.glucose > 250 }.count
+
+                let justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
+                let sumReadings = justGlucoseArray.reduce(0, +)
+
+                let glucoseAverage = Decimal(sumReadings) / total
+                let medianGlucose = StatChartUtils.medianCalculation(array: justGlucoseArray)
+
+                let lowPercentage = Decimal(low) / total * 100
+                let tightPercentage = Decimal(tight) / total * 100
+                let inRangePercentage = Decimal(normal) / total * 100
+                let highPercentage = Decimal(high) / total * 100
+                let moderatelyLowPercentage = Decimal(moderatelyLow) / total * 100
+                let moderatelyHighPercentage = Decimal(moderatelyHigh) / total * 100
+                let veryLowPercentage = Decimal(veryLow) / total * 100
+                let veryHighPercentage = Decimal(veryHigh) / total * 100
+
+                VStack(alignment: .leading, spacing: 10) {
+                    VStack(alignment: .leading, spacing: 5) {
+                        Text(
+                            "\(Decimal(timeInRangeType.bottomThreshold).formatted(for: units))-\(highLimit.formatted(for: units))"
+                        )
+                        .font(.subheadline)
                         .foregroundStyle(Color.secondary)
-                    Text(inRangePercentage.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%")
-                        .foregroundStyle(Color.loopGreen)
-                }
+                        Text(formatPercentage(inRangePercentage, tight: true))
+                            .foregroundStyle(Color.loopGreen)
+                    }
 
-                VStack(alignment: .leading, spacing: 5) {
-                    Text(
-                        "\(formatValue(Decimal(timeInRangeType.bottomThreshold)))-\(formatValue(Decimal(timeInRangeType.topThreshold)))"
-                    )
-                    .font(.subheadline)
-                    .foregroundStyle(Color.secondary)
-                    Text(tightPercentage.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%")
-                        .foregroundStyle(Color.green)
-                }
-            }.padding(.leading, 5)
+                    VStack(alignment: .leading, spacing: 5) {
+                        Text(
+                            "\(Decimal(timeInRangeType.bottomThreshold).formatted(for: units))-\(Decimal(timeInRangeType.topThreshold).formatted(for: units))"
+                        )
+                        .font(.subheadline)
+                        .foregroundStyle(Color.secondary)
+                        Text(formatPercentage(tightPercentage, tight: true))
+                            .foregroundStyle(Color.green)
+                    }
+                }.padding(.leading, 5)
+
+                VStack(alignment: .leading, spacing: 10) {
+                    VStack(alignment: .leading, spacing: 5) {
+                        Text("> \(highLimit.formatted(for: units))").font(.subheadline)
+                            .foregroundStyle(Color.secondary)
+                        Text(formatPercentage(highPercentage, tight: true))
+                            .foregroundStyle(Color.loopYellow)
+                    }
 
-            VStack(alignment: .leading, spacing: 10) {
-                VStack(alignment: .leading, spacing: 5) {
-                    Text("> \(formatValue(highLimit))").font(.subheadline)
+                    VStack(alignment: .leading, spacing: 5) {
+                        Text(
+                            "< \(Decimal(showChart ? timeInRangeType.bottomThreshold : 70).formatted(for: units))"
+                        )
+                        .font(.subheadline)
                         .foregroundStyle(Color.secondary)
-                    Text(highPercentage.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%")
-                        .foregroundStyle(Color.orange)
+                        Text(formatPercentage(lowPercentage, tight: true))
+                            .foregroundStyle(Color.red)
+                    }
                 }
+                // If not showing chart, show extra stats
+                if !showChart {
+                    VStack(alignment: .leading, spacing: 10) {
+                        VStack(alignment: .leading, spacing: 5) {
+                            Text("> \(Decimal(220).formatted(for: units))").font(.subheadline)
+                                .foregroundStyle(Color.secondary)
+                            Text(formatPercentage(moderatelyHighPercentage, tight: true))
+                                .foregroundStyle(Color.loopYellow)
+                        }
 
-                VStack(alignment: .leading, spacing: 5) {
-                    Text(
-                        "< \(formatValue(Decimal(timeInRangeType.bottomThreshold)))"
-                    )
-                    .font(.subheadline)
-                    .foregroundStyle(Color.secondary)
-                    Text(lowPercentage.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%")
-                        .foregroundStyle(Color.loopRed)
-                }
-            }
+                        VStack(alignment: .leading, spacing: 5) {
+                            Text(
+                                "< \(Decimal(63).formatted(for: units))"
+                            )
+                            .font(.subheadline)
+                            .foregroundStyle(Color.secondary)
+                            Text(formatPercentage(moderatelyLowPercentage, tight: true))
+                                .foregroundStyle(Color.red)
+                        }
+                    }
+                    VStack(alignment: .leading, spacing: 10) {
+                        VStack(alignment: .leading, spacing: 5) {
+                            Text("> \(Decimal(250).formatted(for: units))").font(.subheadline)
+                                .foregroundStyle(Color.secondary)
+                            Text(formatPercentage(veryHighPercentage, tight: true))
+                                .foregroundStyle(Color.orange)
+                        }
 
-            VStack(alignment: .leading, spacing: 10) {
-                VStack(alignment: .leading, spacing: 5) {
-                    Text("Average").font(.subheadline).foregroundStyle(Color.secondary)
-                    Text(
-                        units == .mgdL ? glucoseAverage
-                            .formatted(.number.grouping(.never).rounded().precision(.fractionLength(0))) : glucoseAverage.asMmolL
-                            .formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))
-                    )
+                        VStack(alignment: .leading, spacing: 5) {
+                            Text(
+                                "< \(Decimal(54).formatted(for: units))"
+                            )
+                            .font(.subheadline)
+                            .foregroundStyle(Color.secondary)
+                            Text(formatPercentage(veryLowPercentage, tight: true))
+                                .foregroundStyle(Color.purple)
+                        }
+                    }
                 }
 
-                VStack(alignment: .leading, spacing: 5) {
-                    Text("Median").font(.subheadline).foregroundStyle(Color.secondary)
-                    Text(
-                        units == .mgdL ? medianGlucose
-                            .formatted(.number.grouping(.never).rounded().precision(.fractionLength(0))) : medianGlucose.asMmolL
-                            .formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))
-                    )
+                VStack(alignment: .leading, spacing: 10) {
+                    VStack(alignment: .leading, spacing: 5) {
+                        Text(showChart ? "Average" : "Avg").font(.subheadline).foregroundStyle(Color.secondary)
+                        Text(
+                            units == .mgdL ? glucoseAverage
+                                .formatted(.number.grouping(.never).rounded().precision(.fractionLength(0))) : glucoseAverage
+                                .asMmolL
+                                .formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))
+                        )
+                    }
+
+                    VStack(alignment: .leading, spacing: 5) {
+                        Text(showChart ? "Median" : "Med").font(.subheadline).foregroundStyle(Color.secondary)
+                        Text(
+                            units == .mgdL ? medianGlucose
+                                .formatted(.number.grouping(.never).rounded().precision(.fractionLength(0))) : medianGlucose
+                                .asMmolL
+                                .formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))
+                        )
+                    }
                 }
-            }
 
-            Chart {
-                ForEach(rangeData, id: \.range) { data in
-                    SectorMark(
-                        angle: .value("Percentage", data.count),
-                        innerRadius: .ratio(0.618),
-                        outerRadius: selectedRange == data.range ? 100 : 80,
-                        angularInset: 1.5
-                    )
-                    .foregroundStyle(data.color)
+                if showChart {
+                    Chart {
+                        ForEach(rangeData, id: \.range) { data in
+                            SectorMark(
+                                angle: .value("Percentage", data.count),
+                                innerRadius: .ratio(0.618),
+                                outerRadius: selectedRange == data.range ? 100 : 80
+                            )
+                            .foregroundStyle(data.color)
+                        }
+                    }
+                    .chartAngleSelection(value: $selectedCount)
+                    .frame(height: 100)
                 }
             }
-            .chartAngleSelection(value: $selectedCount)
-            .frame(height: 100)
-        }
-        .onChange(of: selectedCount) { _, newValue in
-            if let newValue {
-                withAnimation {
-                    getSelectedRange(value: newValue)
-                }
-            } else {
-                withAnimation {
-                    selectedRange = nil
+            .onChange(of: selectedCount) { _, newValue in
+                if let newValue {
+                    withAnimation {
+                        getSelectedRange(value: newValue)
+                    }
+                } else {
+                    withAnimation {
+                        selectedRange = nil
+                    }
                 }
             }
-        }
-        .overlay(alignment: .top) {
-            if let selectedRange {
-                let data = getDetailedData(for: selectedRange)
-                RangeDetailPopover(data: data)
-                    .transition(.scale.combined(with: .opacity))
-                    .offset(y: -150) // TODO: make this dynamic
+            .overlay(alignment: .top) {
+                if let selectedRange {
+                    let data = getDetailedData(for: selectedRange)
+                    RangeDetailPopover(data: data)
+                        .transition(.scale.combined(with: .opacity))
+                        .offset(y: -150) // TODO: make this dynamic
+                }
             }
         }
     }
@@ -167,7 +230,7 @@ struct GlucoseSectorChart: View {
 
         // Return array of tuples with range data
         return [
-            (.high, highCount, Decimal(highCount) / Decimal(total) * 100, .orange),
+            (.high, highCount, Decimal(highCount) / Decimal(total) * 100, .loopYellow),
             (.inRange, inRangeCount, Decimal(inRangeCount) / Decimal(total) * 100, .green),
             (.low, lowCount, Decimal(lowCount) / Decimal(total) * 100, .red)
         ]
@@ -216,15 +279,18 @@ struct GlucoseSectorChart: View {
 
             return RangeDetail(
                 title: String(localized: "High Glucose"),
-                color: .orange,
+                color: .loopYellow,
                 items: [
-                    (String(localized: "Very High (>\(formatValue(250)))"), formatPercentage(Decimal(veryHigh) / total * 100)),
                     (
-                        String(localized: "High (\(formatValue(highLimit))-\(formatValue(250)))"),
+                        String(localized: "Very High (>\(Decimal(250).formatted(for: units)))"),
+                        formatPercentage(Decimal(veryHigh) / total * 100)
+                    ),
+                    (
+                        String(localized: "High (\(highLimit.formatted(for: units))-\(Decimal(250).formatted(for: units)))"),
                         formatPercentage(Decimal(high) / total * 100)
                     ),
-                    (String(localized: "Average"), formatValue(average)),
-                    (String(localized: "Median"), formatValue(median)),
+                    (String(localized: "Average"), average.formatted(for: units)),
+                    (String(localized: "Median"), median.formatted(for: units)),
                     (String(localized: "SD"), formatSD(standardDeviation))
                 ]
             )
@@ -242,18 +308,18 @@ struct GlucoseSectorChart: View {
                 items: [
                     (
                         String(
-                            localized: "Normal (\(formatValue(Decimal(timeInRangeType.bottomThreshold)))-\(formatValue(highLimit)))"
+                            localized: "Normal (\(Decimal(timeInRangeType.bottomThreshold).formatted(for: units))-\(highLimit.formatted(for: units)))"
                         ),
                         formatPercentage(Decimal(glucoseValues.count) / total * 100)
                     ),
                     (
                         String(
-                            localized: "\(timeInRangeType == .timeInTightRange ? "TITR" : "TING") (\(formatValue(Decimal(timeInRangeType.bottomThreshold)))-\(formatValue(Decimal(timeInRangeType.topThreshold))))"
+                            localized: "\(timeInRangeType == .timeInTightRange ? "TITR" : "TING") (\(Decimal(timeInRangeType.bottomThreshold).formatted(for: units))-\(Decimal(timeInRangeType.topThreshold).formatted(for: units)))"
                         ),
                         formatPercentage(Decimal(tight) / total * 100)
                     ),
-                    (String(localized: "Average"), formatValue(average)),
-                    (String(localized: "Median"), formatValue(median)),
+                    (String(localized: "Average"), average.formatted(for: units)),
+                    (String(localized: "Median"), median.formatted(for: units)),
                     (String(localized: "SD"), formatSD(standardDeviation))
                 ]
             )
@@ -271,12 +337,17 @@ struct GlucoseSectorChart: View {
                 color: .red,
                 items: [
                     (
-                        String(localized: "Low (\(formatValue(54))-\(formatValue(Decimal(timeInRangeType.bottomThreshold))))"),
+                        String(
+                            localized: "Low (\(Decimal(54).formatted(for: units))-\(Decimal(timeInRangeType.bottomThreshold).formatted(for: units)))"
+                        ),
                         formatPercentage(Decimal(low) / total * 100)
                     ),
-                    (String(localized: "Very Low (<\(formatValue(54)))"), formatPercentage(Decimal(veryLow) / total * 100)),
-                    (String(localized: "Average"), formatValue(average)),
-                    (String(localized: "Median"), formatValue(median)),
+                    (
+                        String(localized: "Very Low (<\(Decimal(54).formatted(for: units))"),
+                        formatPercentage(Decimal(veryLow) / total * 100)
+                    ),
+                    (String(localized: "Average"), average.formatted(for: units)),
+                    (String(localized: "Median"), median.formatted(for: units)),
                     (String(localized: "SD"), formatSD(standardDeviation))
                 ]
             )
@@ -286,10 +357,14 @@ struct GlucoseSectorChart: View {
     /// Formats a percentage value to a string with one decimal place.
     /// - Parameter value: A decimal value representing the percentage.
     /// - Returns: A formatted percentage string
-    private func formatPercentage(_ value: Decimal) -> String {
+    private func formatPercentage(_ value: Decimal, tight: Bool = false) -> String {
         let formatter = NumberFormatter()
         formatter.numberStyle = .percent
-        formatter.maximumFractionDigits = 1
+        formatter.minimumFractionDigits = value == 100 ? 0 : 1
+        formatter.maximumFractionDigits = value == 100 ? 0 : 1
+        if tight {
+            formatter.positiveSuffix = "%"
+        }
         return formatter.string(from: NSDecimalNumber(decimal: value / 100)) ?? "0%"
     }
 
@@ -319,13 +394,6 @@ struct GlucoseSectorChart: View {
             .number.grouping(.never).rounded().precision(.fractionLength(0))
         ) : sd.formattedAsMmolL
     }
-
-    /// Formats a glucose value based on the current units.
-    /// - Parameter value: A decimal value representing the glucose level.
-    /// - Returns: A formatted string of the glucose value.
-    private func formatValue(_ value: Decimal) -> String {
-        units == .mgdL ? value.description : value.formattedAsMmolL
-    }
 }
 
 /// Represents details about a specific glucose range category including title, color and percentage breakdowns

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

@@ -250,24 +250,24 @@ struct BolusStatsView: View {
         .chartXAxis {
             AxisMarks(preset: .aligned, values: .stride(by: selectedInterval == .day ? .hour : .day)) { value in
                 if let date = value.as(Date.self) {
-                    let day = Calendar.current.component(.day, from: date)
-                    let hour = Calendar.current.component(.hour, from: date)
-
                     switch selectedInterval {
                     case .day:
+                        let hour = Calendar.current.component(.hour, from: date)
                         if hour % 6 == 0 { // Show only every 6 hours
                             AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
                                 .font(.footnote)
                             AxisGridLine()
                         }
                     case .month:
-                        if day % 3 == 0 { // Only show every 3rd day
+                        let weekday = calendar.component(.weekday, from: date)
+                        if weekday == calendar.firstWeekday { // Only show the first day of the week
                             AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
                                 .font(.footnote)
                             AxisGridLine()
                         }
                     case .total:
                         // Only show every other month
+                        let day = Calendar.current.component(.day, from: date)
                         if day == 1 && Calendar.current.component(.month, from: date) % 2 == 1 {
                             AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
                                 .font(.footnote)

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

@@ -211,7 +211,8 @@ struct TotalDailyDoseChart: View {
                             AxisGridLine()
                         }
                     case .month:
-                        if day % 3 == 0 { // Only show every 3rd day
+                        let weekday = calendar.component(.weekday, from: date)
+                        if weekday == calendar.firstWeekday { // Only show the first day of the week
                             AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
                                 .font(.footnote)
                             AxisGridLine()

+ 5 - 3
Trio/Sources/Modules/Stat/View/ViewElements/Looping/LoopBarChartView.swift

@@ -36,9 +36,11 @@ struct LoopBarChartView: View {
             .chartXAxis {
                 AxisMarks(position: .bottom) { value in
                     if let percentage = value.as(Double.self) {
-                        AxisValueLabel {
-                            Text("\(Int(percentage))%")
-                                .font(.footnote)
+                        if selectedInterval != .today {
+                            AxisValueLabel {
+                                Text("\(Int(percentage))%")
+                                    .font(.footnote)
+                            }
                         }
                         AxisGridLine()
                     }

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

@@ -231,7 +231,6 @@ struct MealStatsView: View {
         .chartXAxis {
             AxisMarks(preset: .aligned, values: .stride(by: selectedInterval == .day ? .hour : .day)) { value in
                 if let date = value.as(Date.self) {
-                    let day = Calendar.current.component(.day, from: date)
                     let hour = Calendar.current.component(.hour, from: date)
 
                     switch selectedInterval {
@@ -242,13 +241,15 @@ struct MealStatsView: View {
                             AxisGridLine()
                         }
                     case .month:
-                        if day % 3 == 0 { // Only show every 3rd day
+                        let weekday = calendar.component(.weekday, from: date)
+                        if weekday == calendar.firstWeekday { // Only show the first day of the week
                             AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
                                 .font(.footnote)
                             AxisGridLine()
                         }
                     case .total:
                         // Only show every other month
+                        let day = Calendar.current.component(.day, from: date)
                         if day == 1 && Calendar.current.component(.month, from: date) % 2 == 1 {
                             AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
                                 .font(.footnote)

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

@@ -55,7 +55,7 @@ extension LiveActivityAttributes.ContentState {
         return formatter.string(from: deltaAsDecimal as NSNumber) ?? "--"
     }
 
-    init?(
+    init(
         new bg: GlucoseData,
         prev _: GlucoseData?,
         units: GlucoseUnits,

+ 108 - 222
Trio/Sources/Services/LiveActivity/LiveActivityManager.swift

@@ -7,7 +7,6 @@ import UIKit
 
 @available(iOS 16.2, *) private struct ActiveActivity {
     let activity: Activity<LiveActivityAttributes>
-    let startDate: Date
 
     /// Determines if the current activity needs to be recreated.
     ///
@@ -23,10 +22,21 @@ import UIKit
         @unknown default:
             return true
         }
-        return -startDate.timeIntervalSinceNow > TimeInterval(60 * 60)
+        return -activity.attributes.startDate.timeIntervalSinceNow > TimeInterval(60 * 60)
     }
 }
 
+final class LiveActivityData: ObservableObject {
+    /// Determination data used to update live activity state.
+    @Published var determination: DeterminationData?
+    /// Array of glucose readings fetched from persistent storage.
+    @Published var glucoseFromPersistence: [GlucoseData]?
+    /// The current override data (if any).
+    @Published var override: OverrideData?
+    /// The widget items displayed within the live activity.
+    @Published var widgetItems: [LiveActivityAttributes.LiveActivityItem]?
+}
+
 /// A service managing live activity updates and state management.
 ///
 /// This class handles the creation, update, and termination of live activities based on various data sources
@@ -51,18 +61,10 @@ final class LiveActivityManager: Injectable, ObservableObject, SettingsObserver
         settingsManager.settings
     }
 
-    /// Determination data used to update live activity state.
-    var determination: DeterminationData?
     /// The current active live activity.
     private var currentActivity: ActiveActivity?
-    /// The most recent glucose reading.
-    private var latestGlucose: GlucoseData?
-    /// Array of glucose readings fetched from persistent storage.
-    var glucoseFromPersistence: [GlucoseData]?
-    /// The current override data (if any).
-    var override: OverrideData?
-    /// The widget items displayed within the live activity.
-    var widgetItems: [LiveActivityAttributes.LiveActivityItem]?
+
+    private var data = LiveActivityData()
 
     /// A Core Data task context.
     let context = CoreDataStack.shared.newTaskContext()
@@ -85,11 +87,16 @@ final class LiveActivityManager: Injectable, ObservableObject, SettingsObserver
         systemEnabled = activityAuthorizationInfo.areActivitiesEnabled
         injectServices(resolver)
         setupNotifications()
-        registerSubscribers()
         registerHandler()
         monitorForLiveActivityAuthorizationChanges()
-        setupGlucoseArray()
         broadcaster.register(SettingsObserver.self, observer: self)
+        data.objectWillChange.sink { [weak self] in
+            Task { @MainActor in
+                // by the time this runs, the object change is done, so we see the new data here
+                await self?.pushCurrentContent()
+            }
+        }.store(in: &subscriptions)
+        loadInitialData()
     }
 
     /// Sets up application notifications that trigger live activity updates when the app state changes.
@@ -98,18 +105,18 @@ final class LiveActivityManager: Injectable, ObservableObject, SettingsObserver
         notificationCenter
             .addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { [weak self] _ in
                 Task { @MainActor in
-                    self?.forceActivityUpdate()
+                    await self?.pushCurrentContent()
                 }
             }
         notificationCenter
             .addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: nil) { [weak self] _ in
                 Task { @MainActor in
-                    self?.forceActivityUpdate()
+                    await self?.pushCurrentContent()
                 }
             }
         notificationCenter.addObserver(
             self,
-            selector: #selector(handleLiveActivityOrderChange),
+            selector: #selector(loadWidgetItems),
             name: .liveActivityOrderDidChange,
             object: nil
         )
@@ -120,143 +127,75 @@ final class LiveActivityManager: Injectable, ObservableObject, SettingsObserver
     /// This method triggers an update to the live activity content state based on the new settings.
     /// - Parameter _: The updated `TrioSettings`.
     func settingsDidChange(_: TrioSettings) {
-        Task {
-            await updateContentState(determination)
+        Task { @MainActor in
+            await self.pushCurrentContent()
         }
     }
 
     /// Registers handlers for Core Data changes related to overrides, glucose readings, and determinations.
     private func registerHandler() {
         coreDataPublisher?.filteredByEntityName("OverrideStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            self.overridesDidUpdate()
+            Task { await self?.loadOverrides() }
         }.store(in: &subscriptions)
 
         coreDataPublisher?.filteredByEntityName("GlucoseStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            self.setupGlucoseArray()
+            Task { await self?.loadGlucose() }
         }.store(in: &subscriptions)
 
         coreDataPublisher?.filteredByEntityName("OrefDetermination")
             .debounce(for: .seconds(2), scheduler: DispatchQueue.global(qos: .utility))
             .sink { [weak self] _ in
-                guard let self = self else { return }
-                self.cobOrIobDidUpdate()
+                Task { await self?.loadDetermination() }
             }.store(in: &subscriptions)
     }
 
-    /// Registers subscribers for updates from the glucose storage.
-    private func registerSubscribers() {
-        glucoseStorage.updatePublisher
-            .receive(on: queue)
-            .sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupGlucoseArray()
-            }
-            .store(in: &subscriptions)
-    }
-
     /// Fetches and maps new determination data and updates the live activity content state.
-    private func cobOrIobDidUpdate() {
-        Task { @MainActor in
-            do {
-                self.determination = try await fetchAndMapDetermination()
-                if let determination = determination {
-                    await self.updateContentState(determination)
-                }
-            } catch {
-                debug(
-                    .default,
-                    "\(DebuggingIdentifiers.failed) failed to fetch and map determination: \(error)"
-                )
-            }
+    private func loadDetermination() async {
+        do {
+            data.determination = try await fetchAndMapDetermination()
+        } catch {
+            debug(
+                .default,
+                "[LiveActivityManager] \(DebuggingIdentifiers.failed) failed to fetch and map determination: \(error)"
+            )
         }
     }
 
     /// Fetches and maps override data and updates the live activity content state.
-    private func overridesDidUpdate() {
-        Task { @MainActor in
-            do {
-                self.override = try await fetchAndMapOverride()
-                if let determination = determination {
-                    await self.updateContentState(determination)
-                }
-            } catch {
-                debug(.default, "\(DebuggingIdentifiers.failed) failed to fetch and map override: \(error)")
-            }
+    private func loadOverrides() async {
+        do {
+            data.override = try await fetchAndMapOverride()
+        } catch {
+            debug(.default, "[LiveActivityManager] \(DebuggingIdentifiers.failed) failed to fetch and map override: \(error)")
         }
     }
 
     /// Handles changes to the live activity order.
     ///
     /// Loads widget items from user defaults and triggers an update to the live activity order.
-    @objc private func handleLiveActivityOrderChange() {
-        Task {
-            self.widgetItems = UserDefaults.standard.loadLiveActivityOrderFromUserDefaults() ?? LiveActivityAttributes
-                .LiveActivityItem.defaultItems
-            await self.updateLiveActivityOrder()
-        }
-    }
-
-    /// Updates the live activity content state based on new determination or override data.
-    ///
-    /// - Parameter update: An object representing new `DeterminationData` or `OverrideData`.
-    @MainActor private func updateContentState<T>(_ update: T) async {
-        guard let latestGlucose = latestGlucose else {
-            return
-        }
-        var content: LiveActivityAttributes.ContentState?
-
-        widgetItems = UserDefaults.standard.loadLiveActivityOrderFromUserDefaults() ?? LiveActivityAttributes
+    @objc private func loadWidgetItems() {
+        data.widgetItems = UserDefaults.standard.loadLiveActivityOrderFromUserDefaults() ?? LiveActivityAttributes
             .LiveActivityItem.defaultItems
+    }
 
-        if let determination = update as? DeterminationData {
-            content = LiveActivityAttributes.ContentState(
-                new: latestGlucose,
-                prev: latestGlucose,
-                units: settings.units,
-                chart: glucoseFromPersistence ?? [],
-                settings: settings,
-                determination: determination,
-                override: override,
-                widgetItems: widgetItems
-            )
-        } else if let override = update as? OverrideData {
-            content = LiveActivityAttributes.ContentState(
-                new: latestGlucose,
-                prev: latestGlucose,
-                units: settings.units,
-                chart: glucoseFromPersistence ?? [],
-                settings: settings,
-                determination: determination,
-                override: override,
-                widgetItems: widgetItems
+    /// Sets up the array of glucose data from persistent storage and triggers an update to the live activity.
+    private func loadGlucose() async {
+        do {
+            data.glucoseFromPersistence = try await fetchAndMapGlucose()
+        } catch {
+            debug(
+                .default,
+                "[LiveActivityManager] \(DebuggingIdentifiers.failed) failed to fetch glucose with error: \(error)"
             )
         }
-
-        if let content = content {
-            await pushUpdate(content)
-        }
     }
 
-    /// Triggers an update of the live activity order.
-    ///
-    /// This method refreshes the activity's content state to reflect any changes in the widget order.
-    @MainActor private func updateLiveActivityOrder() async {
+    private func loadInitialData() {
         Task {
-            await updateContentState(determination)
-        }
-    }
-
-    /// Sets up the array of glucose data from persistent storage and triggers an update to the live activity.
-    private func setupGlucoseArray() {
-        Task { @MainActor in
-            do {
-                self.glucoseFromPersistence = try await fetchAndMapGlucose()
-                glucoseDidUpdate(glucoseFromPersistence ?? [])
-            } catch {
-                debug(.default, "\(DebuggingIdentifiers.failed) failed to fetch glucose with error: \(error)")
-            }
+            await self.loadGlucose()
+            await self.loadOverrides()
+            await self.loadDetermination()
+            self.loadWidgetItems()
         }
     }
 
@@ -273,22 +212,6 @@ final class LiveActivityManager: Injectable, ObservableObject, SettingsObserver
         }
     }
 
-    /// Forces an update to the live activity.
-    ///
-    /// If live activities are enabled and the current activity requires recreation, this method triggers a new glucose update.
-    /// Otherwise, it ends the current live activity.
-    @MainActor private func forceActivityUpdate() {
-        if settings.useLiveActivity {
-            if currentActivity?.needsRecreation() ?? true {
-                glucoseDidUpdate(glucoseFromPersistence ?? [])
-            }
-        } else {
-            Task {
-                await self.endActivity()
-            }
-        }
-    }
-
     /// Pushes an update to the live activity with the specified content state.
     ///
     /// If an existing activity requires recreation or is outdated, this method ends it and starts a new one.
@@ -296,6 +219,23 @@ final class LiveActivityManager: Injectable, ObservableObject, SettingsObserver
     ///
     /// - Parameter state: The new content state to push to the live activity.
     @MainActor private func pushUpdate(_ state: LiveActivityAttributes.ContentState) async {
+        if !settings.useLiveActivity || !systemEnabled {
+            await endActivity()
+            return
+        }
+
+        if currentActivity == nil {
+            // try to restore an existing activity
+            currentActivity = Activity<LiveActivityAttributes>.activities
+                .max { $0.attributes.startDate < $1.attributes.startDate }.map {
+                    ActiveActivity(activity: $0)
+                }
+
+            if let currentActivity {
+                debug(.default, "[LiveActivityManager] Restored live activity: \(currentActivity.activity.id)")
+            }
+        }
+
         // End all unknown activities except the current one
         for unknownActivity in Activity<LiveActivityAttributes>.activities
             .filter({ self.currentActivity?.activity.id != $0.id })
@@ -303,22 +243,14 @@ final class LiveActivityManager: Injectable, ObservableObject, SettingsObserver
             await unknownActivity.end(nil, dismissalPolicy: .immediate)
         }
 
-        // Defensive: capture the current activity at function start
-        let activityAtStart = currentActivity
-
-        if let currentActivity = activityAtStart {
+        if let currentActivity {
             if currentActivity.needsRecreation(), UIApplication.shared.applicationState == .active {
                 debug(.default, "[LiveActivityManager] Ending current activity for recreation: \(currentActivity.activity.id)")
                 await endActivity()
                 // After endActivity(), currentActivity is guaranteed to be nil
                 // No recursive task, but explicitly restart
-                if self.currentActivity == nil {
-                    debug(.default, "[LiveActivityManager] Re-pushing update after recreation.")
-                    await pushUpdate(state)
-                } else {
-                    debug(.default, "[LiveActivityManager] Warning: currentActivity was not nil after endActivity!")
-                }
-                return
+                debug(.default, "[LiveActivityManager] Re-pushing update after recreation.")
+                await pushUpdate(state)
             } else {
                 let content = ActivityContent(
                     state: state,
@@ -345,7 +277,7 @@ final class LiveActivityManager: Injectable, ObservableObject, SettingsObserver
                             date: Date.now,
                             highGlucose: settings.high,
                             lowGlucose: settings.low,
-                            target: determination?.target ?? 100 as Decimal,
+                            target: data.determination?.target ?? 100 as Decimal,
                             glucoseColorScheme: settings.glucoseColorScheme.rawValue,
                             detailedViewState: nil,
                             isInitialState: true
@@ -358,7 +290,7 @@ final class LiveActivityManager: Injectable, ObservableObject, SettingsObserver
                     content: expired,
                     pushType: nil
                 )
-                currentActivity = ActiveActivity(activity: activity, startDate: Date.now)
+                currentActivity = ActiveActivity(activity: activity)
                 debug(.default, "[LiveActivityManager] Created new activity: \(activity.id)")
 
                 // Update the newly created activity with actual data
@@ -367,11 +299,11 @@ final class LiveActivityManager: Injectable, ObservableObject, SettingsObserver
                     staleDate: Date.now.addingTimeInterval(5 * 60)
                 )
                 await activity.update(updateContent)
-                debug(.default, "[LiveActivityManager] Updated new activity with actual data")
+                debug(.default, "[LiveActivityManager] Set initial content for new activity: \(activity.id)")
             } catch {
                 debug(
                     .default,
-                    "\(#file): Error creating new activity: \(error)"
+                    "[LiveActivityManager]: Error creating new activity: \(error)"
                 )
                 // Reset currentActivity on error to allow retry on next update
                 currentActivity = nil
@@ -381,25 +313,20 @@ final class LiveActivityManager: Injectable, ObservableObject, SettingsObserver
 
     /// Ends the current live activity and ensures that all unknown activities are terminated.
     private func endActivity() async {
-        debug(.default, "Ending all live activities...")
+        debug(.default, "[LiveActivityManager] Ending all live activities...")
 
         if let currentActivity {
-            debug(.default, "Ending current activity: \(currentActivity.activity.id)")
+            debug(.default, "[LiveActivityManager] Ending current activity: \(currentActivity.activity.id)")
             await currentActivity.activity.end(nil, dismissalPolicy: .immediate)
             self.currentActivity = nil
         }
 
-        for activity in Activity<LiveActivityAttributes>.activities {
-            debug(.default, "Ending lingering activity: \(activity.id)")
-            await activity.end(nil, dismissalPolicy: .immediate)
-        }
-
         for unknownActivity in Activity<LiveActivityAttributes>.activities {
-            debug(.default, "Ending unknown activity: \(unknownActivity.id)")
+            debug(.default, "[LiveActivityManager] Ending unknown activity: \(unknownActivity.id)")
             await unknownActivity.end(nil, dismissalPolicy: .immediate)
         }
 
-        debug(.default, "All live activities ended.")
+        debug(.default, "[LiveActivityManager] All live activities ended.")
     }
 
     /// Restarts the live activity from a Live Activity Intent.
@@ -407,91 +334,50 @@ final class LiveActivityManager: Injectable, ObservableObject, SettingsObserver
     /// This method mimics xdrip's `restartActivityFromLiveActivityIntent()` behavior by verifying that a valid content state exists,
     /// ending the current live activity, and starting a new one using the current state.
     @MainActor func restartActivityFromLiveActivityIntent() async {
-        guard let latestGlucose = latestGlucose,
-              let determination = determination
-        else {
-            debug(.default, "Cannot restart live activity because required persistent state is not available. Fetching data...")
-            return
-        }
-
-        guard let contentState = LiveActivityAttributes.ContentState(
-            new: latestGlucose,
-            prev: latestGlucose,
-            units: settings.units,
-            chart: glucoseFromPersistence ?? [],
-            settings: settings,
-            determination: determination,
-            override: override,
-            widgetItems: widgetItems
-        ) else {
-            debug(.default, "Cannot restart live activity because content state cannot be created")
-            return
-        }
-
         await endActivity()
 
         while (currentActivity != nil && currentActivity!.activity.activityState != .ended) || Activity<LiveActivityAttributes>
             .activities.contains(where: { $0.activityState != .ended })
         {
-            debug(.default, "Waiting for Live Activity to end...")
+            debug(.default, "[LiveActivityManager] Waiting for Live Activity to end...")
             try? await Task.sleep(nanoseconds: 200_000_000) // 0.2s sleep
         }
 
         // Add additional delay to ensure iOS has fully cleaned up the previous activity
-        debug(.default, "Waiting additional time for iOS to clean up...")
+        debug(.default, "[LiveActivityManager] Waiting additional time for iOS to clean up...")
         try? await Task.sleep(nanoseconds: 1_000_000_000) // 1s additional delay
 
-        Task { @MainActor in
-            await self.pushUpdate(contentState)
-        }
-        debug(.default, "Restarted Live Activity from LiveActivityIntent (via iOS Shortcut)")
+        await pushCurrentContent()
+
+        debug(.default, "[LiveActivityManager] Restarted Live Activity from LiveActivityIntent (via iOS Shortcut)")
     }
 }
 
 @available(iOS 16.2, *)
 extension LiveActivityManager {
-    /// Updates the live activity when new glucose data is available.
-    ///
-    /// This function adjusts the live activity content based on new glucose readings and triggers an update to the live activity.
-    /// - Parameter glucose: An array of `GlucoseData` objects.
-    @MainActor func glucoseDidUpdate(_ glucose: [GlucoseData]) {
-        guard settings.useLiveActivity else {
-            if currentActivity != nil {
-                Task {
-                    await self.endActivity()
-                }
-            }
+    @MainActor func pushCurrentContent() async {
+        guard let glucose = data.glucoseFromPersistence, let bg = glucose.first else {
+            debug(.default, "[LiveActivityManager] pushCurrentContent: no current glucose data available")
             return
         }
+        let prevGlucose = data.glucoseFromPersistence?.dropFirst().first
 
-        if glucose.count > 1 {
-            latestGlucose = glucose.dropFirst().first
-        }
-        defer {
-            self.latestGlucose = glucose.first
-        }
-
-        guard let bg = glucose.first else {
+        guard let determination = data.determination else {
+            debug(.default, "[LiveActivityManager] pushCurrentContent: no determination available")
             return
         }
 
-        if let determination = determination {
-            let content = LiveActivityAttributes.ContentState(
-                new: bg,
-                prev: latestGlucose,
-                units: settings.units,
-                chart: glucose,
-                settings: settings,
-                determination: determination,
-                override: override,
-                widgetItems: widgetItems
-            )
+        let content = LiveActivityAttributes.ContentState(
+            new: bg,
+            prev: prevGlucose,
+            units: settings.units,
+            chart: glucose,
+            settings: settings,
+            determination: determination,
+            override: data.override,
+            widgetItems: data.widgetItems
+        )
 
-            if let content = content {
-                Task {
-                    await self.pushUpdate(content)
-                }
-            }
-        }
+        await pushUpdate(content)
     }
 }

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

@@ -15,6 +15,7 @@ import Swinject
     @Injected() var apsManager: APSManager!
     @Injected() var overrideStorage: OverrideStorage!
     @Injected() var liveActivityManager: LiveActivityManager!
+    @Injected() var pumpHistoryStorage: PumpHistoryStorage!
 
     let resolver: Resolver
 

+ 34 - 12
Trio/Sources/Shortcuts/Bolus/BolusIntent.swift

@@ -27,20 +27,36 @@ import Swinject
     ) var bolusQuantity: Double
 
     @Parameter(
+        title: LocalizedStringResource("External Insulin"),
+        description: LocalizedStringResource("If toggled, Insulin will be added to IOB but it will not be delivered"),
+        default: false,
+        requestValueDialog: IntentDialog(stringLiteral: String(localized: "External Insulin?"))
+    ) var externalInsulin: Bool
+
+    @Parameter(
         title: LocalizedStringResource("Confirm Before applying"),
         description: LocalizedStringResource("If toggled, you will need to confirm before applying."),
         default: true
     ) var confirmBeforeApplying: Bool
 
     static var parameterSummary: some ParameterSummary {
-        When(\.$confirmBeforeApplying, .equalTo, true, {
-            Summary("Applying \(\.$bolusQuantity) U") {
+        When(\.$externalInsulin, .equalTo, true, {
+            Summary("Log external insulin bolus \(\.$bolusQuantity) U") {
+                \.$externalInsulin
                 \.$confirmBeforeApplying
             }
         }, otherwise: {
-            Summary("Immediately applying \(\.$bolusQuantity) U") {
-                \.$confirmBeforeApplying
-            }
+            When(\.$confirmBeforeApplying, .equalTo, true, {
+                Summary("Applying \(\.$bolusQuantity) U") {
+                    \.$externalInsulin
+                    \.$confirmBeforeApplying
+                }
+            }, otherwise: {
+                Summary("Immediately applying \(\.$bolusQuantity) U") {
+                    \.$externalInsulin
+                    \.$confirmBeforeApplying
+                }
+            })
         })
     }
 
@@ -55,18 +71,24 @@ import Swinject
                         dialog: IntentDialog(
                             stringLiteral: String(
                                 localized:
-                                "Are you sure to bolus \(bolusFormatted) U of insulin?"
+                                externalInsulin ? "Are you sure to log \(bolusFormatted) U of external insulin?" :
+                                    "Are you sure to bolus \(bolusFormatted) U of insulin?"
                             )
                         )
                     )
                 )
             }
-
-            let finalBolusDisplay = try await BolusIntentRequest().bolus(amount)
-            return .result(
-                dialog: IntentDialog(stringLiteral: finalBolusDisplay)
-            )
-
+            if externalInsulin {
+                let finalExternalBolusDisplay = try await BolusIntentRequest().bolusExternal(amount)
+                return .result(
+                    dialog: IntentDialog(stringLiteral: finalExternalBolusDisplay)
+                )
+            } else {
+                let finalBolusDisplay = try await BolusIntentRequest().bolus(amount)
+                return .result(
+                    dialog: IntentDialog(stringLiteral: finalBolusDisplay)
+                )
+            }
         } catch {
             throw error
         }

+ 21 - 0
Trio/Sources/Shortcuts/Bolus/BolusIntentRequest.swift

@@ -30,4 +30,25 @@ import Foundation
             )
         }
     }
+
+    func bolusExternal(_ bolusAmount: Double) async throws -> String {
+        var bolusQuantity: Decimal = 0
+        var maxExternal: Decimal { settingsManager.pumpSettings.maxBolus * 3 }
+        if Decimal(bolusAmount) > maxExternal {
+            return String(
+                localized:
+                "The external bolus cannot be larger than 3 x the pump setting max bolus (\(settingsManager.pumpSettings.maxBolus.description))."
+            )
+        } else {
+            bolusQuantity = apsManager.roundBolus(amount: Decimal(bolusAmount))
+            await pumpHistoryStorage.storeExternalInsulinEvent(amount: bolusQuantity, timestamp: Date())
+            // perform determine basal sync
+            try await apsManager.determineBasalSync()
+
+            return String(
+                localized:
+                "An external bolus of \(bolusQuantity.formatted()) U of insulin was recorded."
+            )
+        }
+    }
 }

+ 4 - 1
TrioTests/BolusCalculatorTests/BolusCalculatorTests.swift

@@ -662,7 +662,10 @@ import Testing
         }
     }
 
-    @Test("Calculate insulin with backdated carbs") func testHandleBolusCalculationFunction() async throws {
+    @Test(
+        "Calculate insulin with backdated carbs",
+        .enabled(if: false, "Flaky test, disabled while investigating")
+    ) func testHandleBolusCalculationFunction() async throws {
         // STEP 1: Setup test scenario
         let currentDate = Date()
         let backdatedCarbsDate = currentDate.addingTimeInterval(-120 * 60) // 2 hours ago

+ 4 - 1
TrioTests/CoreDataTests/GlucoseStorageTests.swift

@@ -131,7 +131,10 @@ import Testing
         #expect(entry.eventType == .capillaryGlucose, "Type should be capillaryGlucose")
     }
 
-    @Test("Test glucose alarms") func testGlucoseAlarms() async throws {
+    @Test(
+        "Test glucose alarms",
+        .enabled(if: false, "Flaky test, disabled while investigating")
+    ) func testGlucoseAlarms() async throws {
         // Given
         let lowGlucose = [
             BloodGlucose(direction: BloodGlucose.Direction.flat, date: 123, dateString: Date(), glucose: 55)

+ 9 - 4
fastlane/Fastfile

@@ -180,10 +180,15 @@ platform :ios do
     )
 
     def configure_bundle_id(name, identifier, capabilities)
-      bundle_id = Spaceship::ConnectAPI::BundleId.find(identifier) || Spaceship::ConnectAPI::BundleId.create(name: name, identifier: identifier)
-      capabilities.each { |capability|
-        bundle_id.create_capability(capability)
-      }
+      bundle_id = Spaceship::ConnectAPI::BundleId.find(identifier) || Spaceship::ConnectAPI::BundleId.create(
+        name:       name,
+        identifier: identifier,
+        platform:   "IOS"
+      )
+      existing = bundle_id.get_capabilities.map(&:capability_type)
+      capabilities.reject { |c| existing.include?(c) }.each do |cap|
+        bundle_id.create_capability(cap)
+      end
     end
 
     configure_bundle_id("Trio", "#{BUNDLE_ID}", [