Explorar o código

New stats (#516)

* Enhance tab customization system.
* Add statistics view
* Add treatment list
* Move timezone setting into General settings
* Enhance TreatmentsView with time zone support and UI adjustments
Daniel Mini Johansson hai 4 meses
pai
achega
6ada9f2ca5
Modificáronse 40 ficheiros con 5781 adicións e 441 borrados
  1. 1 1
      BuildTools/Package.swift
  2. 14 0
      LoopFollow.xcodeproj/project.pbxproj
  3. 0 96
      LoopFollow.xcworkspace/xcshareddata/swiftpm/Package.resolved
  4. 1 1
      LoopFollow/Application/Base.lproj/Main.storyboard
  5. 3 1
      LoopFollow/Controllers/Nightscout/Treatments.swift
  6. 19 0
      LoopFollow/Helpers/DateTime.swift
  7. 81 3
      LoopFollow/Helpers/TabPosition.swift
  8. 31 0
      LoopFollow/Settings/GeneralSettingsView.swift
  9. 0 27
      LoopFollow/Settings/GraphSettingsView.swift
  10. 76 0
      LoopFollow/Settings/HomeContentView.swift
  11. 60 46
      LoopFollow/Settings/SettingsMenuView.swift
  12. 238 108
      LoopFollow/Settings/TabCustomizationModal.swift
  13. 70 0
      LoopFollow/Stats/AGP/AGPCalculator.swift
  14. 13 0
      LoopFollow/Stats/AGP/AGPDataPoint.swift
  15. 144 0
      LoopFollow/Stats/AGP/AGPGraphView.swift
  16. 50 0
      LoopFollow/Stats/AGP/AGPView.swift
  17. 25 0
      LoopFollow/Stats/AGP/AGPViewModel.swift
  18. 424 0
      LoopFollow/Stats/AggregatedStatsView.swift
  19. 72 0
      LoopFollow/Stats/AggregatedStatsViewModel.swift
  20. 25 0
      LoopFollow/Stats/ChartContainerView.swift
  21. 117 0
      LoopFollow/Stats/DataAvailabilityCalculator.swift
  22. 114 0
      LoopFollow/Stats/DataAvailabilityView.swift
  23. 294 0
      LoopFollow/Stats/DateRangePicker.swift
  24. 84 0
      LoopFollow/Stats/GRI/GRICalculator.swift
  25. 138 0
      LoopFollow/Stats/GRI/GRIRiskGridView.swift
  26. 119 0
      LoopFollow/Stats/GRI/GRIView.swift
  27. 38 0
      LoopFollow/Stats/GRI/GRIViewModel.swift
  28. 336 0
      LoopFollow/Stats/SimpleStatsViewModel.swift
  29. 559 0
      LoopFollow/Stats/StatsDataFetcher.swift
  30. 151 0
      LoopFollow/Stats/StatsDataService.swift
  31. 132 0
      LoopFollow/Stats/TIR/TIRCalculator.swift
  32. 36 0
      LoopFollow/Stats/TIR/TIRDataPoint.swift
  33. 101 0
      LoopFollow/Stats/TIR/TIRGraphView.swift
  34. 166 0
      LoopFollow/Stats/TIR/TIRView.swift
  35. 36 0
      LoopFollow/Stats/TIR/TIRViewModel.swift
  36. 23 2
      LoopFollow/Storage/Storage+Migrate.swift
  37. 59 1
      LoopFollow/Storage/Storage.swift
  38. 1438 0
      LoopFollow/Treatments/TreatmentsView.swift
  39. 264 101
      LoopFollow/ViewControllers/MainViewController.swift
  40. 229 54
      LoopFollow/ViewControllers/MoreMenuViewController.swift

+ 1 - 1
BuildTools/Package.swift

@@ -9,7 +9,7 @@ let package = Package(
     dependencies: [
         .package(
             url: "https://github.com/nicklockwood/SwiftFormat.git",
-            from: "0.56.1"
+            .exact("0.56.1")
         ),
     ],
     targets: [

+ 14 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -16,6 +16,7 @@
 		656F8C102E49F36F0008DC1D /* QRCodeDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */; };
 		656F8C122E49F3780008DC1D /* QRCodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */; };
 		656F8C142E49F3D20008DC1D /* RemoteCommandSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */; };
+		657F98182F043D8100F732BD /* HomeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657F98172F043D8100F732BD /* HomeContentView.swift */; };
 		657F99E92F0BC81500F732BD /* OTPSecureMessenger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657F99E82F0BC81500F732BD /* OTPSecureMessenger.swift */; };
 		6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; };
 		6589CC622E9E7D1600BB18FE /* ImportExportSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC532E9E7D1600BB18FE /* ImportExportSettingsView.swift */; };
@@ -34,6 +35,7 @@
 		6589CC6F2E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC572E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift */; };
 		6589CC712E9E814F00BB18FE /* AlarmSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */; };
 		6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */; };
+		6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; };
 		65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; };
 		65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; };
 		DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; };
@@ -419,6 +421,7 @@
 		656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeDisplayView.swift; sourceTree = "<group>"; };
 		656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeGenerator.swift; sourceTree = "<group>"; };
 		656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteCommandSettings.swift; sourceTree = "<group>"; };
+		657F98172F043D8100F732BD /* HomeContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeContentView.swift; sourceTree = "<group>"; };
 		657F99E82F0BC81500F732BD /* OTPSecureMessenger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPSecureMessenger.swift; sourceTree = "<group>"; };
 		6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = "<group>"; };
 		6589CC522E9E7D1600BB18FE /* ExportableSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportableSettings.swift; sourceTree = "<group>"; };
@@ -437,6 +440,7 @@
 		6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationModal.swift; sourceTree = "<group>"; };
 		6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSelectionView.swift; sourceTree = "<group>"; };
 		6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMigrationManager.swift; sourceTree = "<group>"; };
+		6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = "<group>"; };
 		65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = "<group>"; };
 		65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = "<group>"; };
 		A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -810,6 +814,8 @@
 /* End PBXFileReference section */
 
 /* Begin PBXFileSystemSynchronizedRootGroup section */
+		65AC25F52ECFD5E800421360 /* Stats */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Stats; sourceTree = "<group>"; };
+		65AC26702ED245DF00421360 /* Treatments */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Treatments; sourceTree = "<group>"; };
 		DDCC3AD72DDE1790006F1C10 /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = "<group>"; };
 /* End PBXFileSystemSynchronizedRootGroup section */
 
@@ -860,6 +866,7 @@
 				6589CC5C2E9E7D1600BB18FE /* DexcomSettingsViewModel.swift */,
 				6589CC5D2E9E7D1600BB18FE /* GeneralSettingsView.swift */,
 				6589CC5E2E9E7D1600BB18FE /* GraphSettingsView.swift */,
+				657F98172F043D8100F732BD /* HomeContentView.swift */,
 				6589CC5F2E9E7D1600BB18FE /* SettingsMenuView.swift */,
 				6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */,
 			);
@@ -1477,6 +1484,8 @@
 			isa = PBXGroup;
 			children = (
 				6589CC612E9E7D1600BB18FE /* Settings */,
+				65AC26702ED245DF00421360 /* Treatments */,
+				65AC25F52ECFD5E800421360 /* Stats */,
 				DDCF9A7E2D85FCE6004DF4DD /* Alarm */,
 				FC16A97624995FEE003D6245 /* Application */,
 				DDFF3D792D140F1800BF9D9E /* BackgroundRefresh */,
@@ -1633,6 +1642,10 @@
 			);
 			dependencies = (
 			);
+			fileSystemSynchronizedGroups = (
+				65AC25F52ECFD5E800421360 /* Stats */,
+				65AC26702ED245DF00421360 /* Treatments */,
+			);
 			name = LoopFollow;
 			packageProductDependencies = (
 				DD48781B2C7DAF140048F05C /* SwiftJWT */,
@@ -2064,6 +2077,7 @@
 				6589CC6C2E9E7D1600BB18FE /* GraphSettingsView.swift in Sources */,
 				6589CC6D2E9E7D1600BB18FE /* CalendarSettingsView.swift in Sources */,
 				6589CC6E2E9E7D1600BB18FE /* SettingsMenuView.swift in Sources */,
+				657F98182F043D8100F732BD /* HomeContentView.swift in Sources */,
 				6589CC6F2E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift in Sources */,
 				DD493ADF2ACF22BB009A6922 /* SAge.swift in Sources */,
 				DDC6CA3F2DD7C6340060EE25 /* TemporaryAlarmEditor.swift in Sources */,

+ 0 - 96
LoopFollow.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -1,96 +0,0 @@
-{
-  "originHash" : "94a024be279d128a7e82f3c76785db1e4cf7c9380d0c4aa59dfdf54952403b8d",
-  "pins" : [
-    {
-      "identity" : "bluecryptor",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/Kitura/BlueCryptor.git",
-      "state" : {
-        "revision" : "cec97c24b111351e70e448972a7d3fe68a756d6d",
-        "version" : "2.0.2"
-      }
-    },
-    {
-      "identity" : "blueecc",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/Kitura/BlueECC.git",
-      "state" : {
-        "revision" : "1485268a54f8135435a825a855e733f026fa6cc8",
-        "version" : "1.2.201"
-      }
-    },
-    {
-      "identity" : "bluersa",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/Kitura/BlueRSA.git",
-      "state" : {
-        "revision" : "440f78db26d8bb073f29590f1c7bd31004da09ae",
-        "version" : "1.0.201"
-      }
-    },
-    {
-      "identity" : "cryptoswift",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/krzyzanowskim/CryptoSwift.git",
-      "state" : {
-        "revision" : "e45a26384239e028ec87fbcc788f513b67e10d8f",
-        "version" : "1.9.0"
-      }
-    },
-    {
-      "identity" : "kituracontracts",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/Kitura/KituraContracts.git",
-      "state" : {
-        "revision" : "6edf7ac3dd2b3a2c61284778d430bbad7d8a6f23",
-        "version" : "2.0.1"
-      }
-    },
-    {
-      "identity" : "loggerapi",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/Kitura/LoggerAPI.git",
-      "state" : {
-        "revision" : "4e6b45e850ffa275e8e26a24c6454fd709d5b6ac",
-        "version" : "2.0.0"
-      }
-    },
-    {
-      "identity" : "swift-asn1",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/apple/swift-asn1.git",
-      "state" : {
-        "revision" : "f70225981241859eb4aa1a18a75531d26637c8cc",
-        "version" : "1.4.0"
-      }
-    },
-    {
-      "identity" : "swift-crypto",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/apple/swift-crypto.git",
-      "state" : {
-        "revision" : "e8d6eba1fef23ae5b359c46b03f7d94be2f41fed",
-        "version" : "3.12.3"
-      }
-    },
-    {
-      "identity" : "swift-jwt",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/Kitura/Swift-JWT.git",
-      "state" : {
-        "revision" : "f68ec28fbd90a651597e9e825ea7f315f8d52a1f",
-        "version" : "4.0.1"
-      }
-    },
-    {
-      "identity" : "swift-log",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/apple/swift-log.git",
-      "state" : {
-        "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537",
-        "version" : "1.6.1"
-      }
-    }
-  ],
-  "version" : 3
-}

+ 1 - 1
LoopFollow/Application/Base.lproj/Main.storyboard

@@ -13,7 +13,7 @@
         <!--Home-->
         <scene sceneID="hNz-n2-bh7">
             <objects>
-                <viewController id="9pv-A4-QxB" userLabel="Home" customClass="MainViewController" customModule="LoopFollow" customModuleProvider="target" sceneMemberID="viewController">
+                <viewController storyboardIdentifier="MainViewController" id="9pv-A4-QxB" userLabel="Home" customClass="MainViewController" customModule="LoopFollow" customModuleProvider="target" sceneMemberID="viewController">
                     <view key="view" contentMode="scaleToFill" id="tsR-hK-woN">
                         <rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
                         <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>

+ 3 - 1
LoopFollow/Controllers/Nightscout/Treatments.swift

@@ -10,10 +10,12 @@ extension MainViewController {
         if !Storage.shared.downloadTreatments.value { return }
 
         let startTimeString = dateTimeUtils.getDateTimeString(addingDays: -1 * Storage.shared.downloadDays.value)
-        let currentTimeString = dateTimeUtils.getDateTimeString(addingHours: 6)
+        let currentTimeString = dateTimeUtils.getDateTimeString()
+        let estimatedCount = max(Storage.shared.downloadDays.value * 100, 5000)
         let parameters: [String: String] = [
             "find[created_at][$gte]": startTimeString,
             "find[created_at][$lte]": currentTimeString,
+            "count": "\(estimatedCount)",
         ]
         NightscoutUtils.executeDynamicRequest(eventType: .treatments, parameters: parameters) { (result: Result<Any, Error>) in
             switch result {

+ 19 - 0
LoopFollow/Helpers/DateTime.swift

@@ -4,6 +4,25 @@
 import Foundation
 
 class dateTimeUtils {
+    static func displayTimeZone() -> TimeZone {
+        if Storage.shared.graphTimeZoneEnabled.value,
+           let tz = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value)
+        {
+            return tz
+        }
+        return .current
+    }
+
+    static func displayCalendar() -> Calendar {
+        var calendar = Calendar.current
+        calendar.timeZone = displayTimeZone()
+        return calendar
+    }
+
+    static func applyDisplayTimeZone(to formatter: DateFormatter) {
+        formatter.timeZone = displayTimeZone()
+    }
+
     static func getTimeIntervalMidnightToday() -> TimeInterval {
         let now = Date()
         let formatter = DateFormatter()

+ 81 - 3
LoopFollow/Helpers/TabPosition.swift

@@ -1,18 +1,96 @@
 // LoopFollow
 // TabPosition.swift
 
-enum TabPosition: String, CaseIterable, Codable {
+enum TabPosition: String, CaseIterable, Codable, Comparable {
+    case position1
     case position2
+    case position3
     case position4
+    case menu
     case more
     case disabled
 
     var displayName: String {
         switch self {
+        case .position1: return "Tab 1"
         case .position2: return "Tab 2"
+        case .position3: return "Tab 3"
         case .position4: return "Tab 4"
-        case .more: return "More Menu"
-        case .disabled: return "Hidden"
+        case .menu, .more, .disabled: return "Menu"
         }
     }
+
+    /// The index in the tab bar (0-based)
+    var tabIndex: Int? {
+        switch self {
+        case .position1: return 0
+        case .position2: return 1
+        case .position3: return 2
+        case .position4: return 3
+        case .menu, .more, .disabled: return 4
+        }
+    }
+
+    /// Positions that users can customize (1-4)
+    static var customizablePositions: [TabPosition] {
+        [.position1, .position2, .position3, .position4]
+    }
+
+    /// Normalize legacy values to current values
+    var normalized: TabPosition {
+        switch self {
+        case .more, .disabled: return .menu
+        default: return self
+        }
+    }
+
+    // Comparable conformance for sorting
+    static func < (lhs: TabPosition, rhs: TabPosition) -> Bool {
+        let order: [TabPosition] = [.position1, .position2, .position3, .position4, .menu, .more, .disabled]
+        guard let lhsIndex = order.firstIndex(of: lhs),
+              let rhsIndex = order.firstIndex(of: rhs) else { return false }
+        return lhsIndex < rhsIndex
+    }
+}
+
+/// Represents a tab item that can be placed in any position
+enum TabItem: String, CaseIterable, Codable, Identifiable {
+    case home
+    case alarms
+    case remote
+    case nightscout
+    case snoozer
+    case treatments
+    case stats
+
+    var id: String { rawValue }
+
+    var displayName: String {
+        switch self {
+        case .home: return "Home"
+        case .alarms: return "Alarms"
+        case .remote: return "Remote"
+        case .nightscout: return "Nightscout"
+        case .snoozer: return "Snoozer"
+        case .treatments: return "Treatments"
+        case .stats: return "Statistics"
+        }
+    }
+
+    var icon: String {
+        switch self {
+        case .home: return "house"
+        case .alarms: return "alarm"
+        case .remote: return "antenna.radiowaves.left.and.right"
+        case .nightscout: return "safari"
+        case .snoozer: return "zzz"
+        case .treatments: return "cross.case"
+        case .stats: return "chart.bar.xaxis"
+        }
+    }
+
+    /// Items that can be moved between tab bar and menu (all except settings which doesn't exist as a tab)
+    static var movableItems: [TabItem] {
+        [.home, .alarms, .remote, .nightscout, .snoozer, .treatments, .stats]
+    }
 }

+ 31 - 0
LoopFollow/Settings/GeneralSettingsView.swift

@@ -15,6 +15,8 @@ struct GeneralSettingsView: View {
     @ObservedObject var snoozerEmoji = Storage.shared.snoozerEmoji
     @ObservedObject var forcePortraitMode = Storage.shared.forcePortraitMode
     @ObservedObject var persistentNotification = Storage.shared.persistentNotification
+    @ObservedObject var graphTimeZoneEnabled = Storage.shared.graphTimeZoneEnabled
+    @ObservedObject var graphTimeZoneIdentifier = Storage.shared.graphTimeZoneIdentifier
 
     // Speak-BG settings
     @ObservedObject var speakBG = Storage.shared.speakBG
@@ -61,6 +63,20 @@ struct GeneralSettingsView: View {
                         }
                 }
 
+                Section("Time Zone") {
+                    Toggle("Time Zone Override", isOn: $graphTimeZoneEnabled.value)
+                        .onChange(of: graphTimeZoneEnabled.value) { _ in markChartSettingsDirty() }
+
+                    if graphTimeZoneEnabled.value {
+                        Picker("Time Zone", selection: $graphTimeZoneIdentifier.value) {
+                            ForEach(Self.sortedTimeZones, id: \.identifier) { tz in
+                                Text(Self.timeZoneLabel(tz)).tag(tz.identifier)
+                            }
+                        }
+                        .onChange(of: graphTimeZoneIdentifier.value) { _ in markChartSettingsDirty() }
+                    }
+                }
+
                 Section("Speak BG") {
                     Toggle("Speak BG", isOn: $speakBG.value.animation())
 
@@ -122,4 +138,19 @@ struct GeneralSettingsView: View {
         .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .navigationBarTitle("General Settings", displayMode: .inline)
     }
+
+    private func markChartSettingsDirty() {
+        Observable.shared.chartSettingsChanged.value = true
+    }
+
+    private static let sortedTimeZones: [TimeZone] = TimeZone.knownTimeZoneIdentifiers
+        .compactMap { TimeZone(identifier: $0) }
+        .sorted { $0.secondsFromGMT() < $1.secondsFromGMT() }
+
+    private static func timeZoneLabel(_ tz: TimeZone) -> String {
+        let offsetMinutes = tz.secondsFromGMT() / 60
+        let sign = offsetMinutes >= 0 ? "+" : "-"
+        let offsetString = String(format: "UTC%@%02d:%02d", sign, abs(offsetMinutes) / 60, abs(offsetMinutes) % 60)
+        return "(\(offsetString)) \(tz.identifier)"
+    }
 }

+ 0 - 27
LoopFollow/Settings/GraphSettingsView.swift

@@ -13,8 +13,6 @@ struct GraphSettingsView: View {
     @ObservedObject private var show90MinLine = Storage.shared.show90MinLine
     @ObservedObject private var showMidnightLines = Storage.shared.showMidnightLines
     @ObservedObject private var smallGraphTreatments = Storage.shared.smallGraphTreatments
-    @ObservedObject private var graphTimeZoneEnabled = Storage.shared.graphTimeZoneEnabled
-    @ObservedObject private var graphTimeZoneIdentifier = Storage.shared.graphTimeZoneIdentifier
 
     @ObservedObject private var smallGraphHeight = Storage.shared.smallGraphHeight
     @ObservedObject private var predictionToLoad = Storage.shared.predictionToLoad
@@ -50,18 +48,6 @@ struct GraphSettingsView: View {
 
                     Toggle("Show Midnight Lines", isOn: $showMidnightLines.value)
                         .onChange(of: showMidnightLines.value) { _ in markDirty() }
-
-                    Toggle("Time Zone Override", isOn: $graphTimeZoneEnabled.value)
-                        .onChange(of: graphTimeZoneEnabled.value) { _ in markDirty() }
-
-                    if graphTimeZoneEnabled.value {
-                        Picker("Time Zone", selection: $graphTimeZoneIdentifier.value) {
-                            ForEach(Self.sortedTimeZones, id: \.identifier) { tz in
-                                Text(Self.timeZoneLabel(tz)).tag(tz.identifier)
-                            }
-                        }
-                        .onChange(of: graphTimeZoneIdentifier.value) { _ in markDirty() }
-                    }
                 }
 
                 // ── Treatments ───────────────────────────────────────────────
@@ -154,17 +140,4 @@ struct GraphSettingsView: View {
     private func markDirty() {
         Observable.shared.chartSettingsChanged.value = true
     }
-
-    // MARK: - Time Zone Helpers
-
-    private static let sortedTimeZones: [TimeZone] = TimeZone.knownTimeZoneIdentifiers
-        .compactMap { TimeZone(identifier: $0) }
-        .sorted { $0.secondsFromGMT() < $1.secondsFromGMT() }
-
-    private static func timeZoneLabel(_ tz: TimeZone) -> String {
-        let offsetMinutes = tz.secondsFromGMT() / 60
-        let sign = offsetMinutes >= 0 ? "+" : "-"
-        let offsetString = String(format: "UTC%@%02d:%02d", sign, abs(offsetMinutes) / 60, abs(offsetMinutes) % 60)
-        return "(\(offsetString)) \(tz.identifier)"
-    }
 }

+ 76 - 0
LoopFollow/Settings/HomeContentView.swift

@@ -0,0 +1,76 @@
+// LoopFollow
+// HomeContentView.swift
+
+import SwiftUI
+import UIKit
+
+/// A SwiftUI wrapper around MainViewController that displays the full Home screen.
+/// This can be used both in the tab bar and as a modal from the Menu.
+struct HomeContentView: UIViewControllerRepresentable {
+    let isModal: Bool
+
+    init(isModal: Bool = false) {
+        self.isModal = isModal
+    }
+
+    func makeUIViewController(context _: Context) -> UIViewController {
+        let storyboard = UIStoryboard(name: "Main", bundle: nil)
+
+        // Get the MainViewController from storyboard
+        guard let mainVC = storyboard.instantiateViewController(withIdentifier: "MainViewController") as? MainViewController else {
+            let fallbackVC = UIViewController()
+            fallbackVC.view.backgroundColor = .systemBackground
+            let label = UILabel()
+            label.text = "Unable to load Home screen"
+            label.textAlignment = .center
+            label.translatesAutoresizingMaskIntoConstraints = false
+            fallbackVC.view.addSubview(label)
+            NSLayoutConstraint.activate([
+                label.centerXAnchor.constraint(equalTo: fallbackVC.view.centerXAnchor),
+                label.centerYAnchor.constraint(equalTo: fallbackVC.view.centerYAnchor),
+            ])
+            return fallbackVC
+        }
+
+        mainVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
+
+        mainVC.isPresentedAsModal = isModal
+
+        return mainVC
+    }
+
+    func updateUIViewController(_ uiViewController: UIViewController, context _: Context) {
+        uiViewController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
+    }
+}
+
+// MARK: - Modal wrapper with navigation bar
+
+struct HomeModalView: View {
+    @Environment(\.dismiss) private var dismiss
+
+    var body: some View {
+        NavigationView {
+            HomeContentView(isModal: true)
+                .navigationTitle("Home")
+                .navigationBarTitleDisplayMode(.inline)
+                .toolbar {
+                    ToolbarItem(placement: .navigationBarTrailing) {
+                        Button {
+                            dismiss()
+                        } label: {
+                            Image(systemName: "checkmark")
+                        }
+                        .foregroundColor(.blue)
+                    }
+                }
+        }
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
+    }
+}
+
+// MARK: - Preview
+
+#Preview {
+    HomeModalView()
+}

+ 60 - 46
LoopFollow/Settings/SettingsMenuView.swift

@@ -76,13 +76,7 @@ struct SettingsMenuView: View {
                 }
 
                 // ───────── Alarms ─────────
-                Section {
-                    NavigationRow(title: "Alarms",
-                                  icon: "bell")
-                    {
-                        settingsPath.value.append(Sheet.alarmsList)
-                    }
-
+                Section("Alarms") {
                     NavigationRow(title: "Alarm Settings",
                                   icon: "bell.badge")
                     {
@@ -126,13 +120,6 @@ struct SettingsMenuView: View {
                               action: shareLogs)
                 }
 
-                // ───────── Community ─────────
-                Section("Community") {
-                    LinkRow(title: "LoopFollow Facebook Group",
-                            icon: "person.2.fill",
-                            url: URL(string: "https://www.facebook.com/groups/loopfollowlnl")!)
-                }
-
                 // ───────── Build info ─────────
                 buildInfoSection
             }
@@ -142,8 +129,7 @@ struct SettingsMenuView: View {
                 TabCustomizationModal(
                     isPresented: $showingTabCustomization,
                     onApply: {
-                        // Dismiss any presented view controller and go to home tab
-                        handleTabReorganization()
+                        // No-op - changes are applied silently via observers
                     }
                 )
             }
@@ -239,34 +225,8 @@ struct SettingsMenuView: View {
     }
 
     private func handleTabReorganization() {
-        // Find the root tab bar controller
-        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
-              let window = windowScene.windows.first,
-              let rootVC = window.rootViewController else { return }
-
-        // Navigate through the hierarchy to find the tab bar controller
-        var tabBarController: UITabBarController?
-
-        if let tbc = rootVC as? UITabBarController {
-            tabBarController = tbc
-        } else if let nav = rootVC as? UINavigationController,
-                  let tbc = nav.viewControllers.first as? UITabBarController
-        {
-            tabBarController = tbc
-        }
-
-        guard let tabBar = tabBarController else { return }
-
-        // Dismiss any modals first
-        if let presented = tabBar.presentedViewController {
-            presented.dismiss(animated: false) {
-                // After dismissal, switch to home tab
-                tabBar.selectedIndex = 0
-            }
-        } else {
-            // No modal to dismiss, just switch to home
-            tabBar.selectedIndex = 0
-        }
+        // Rebuild the tab bar with the new configuration
+        MainViewController.rebuildTabsIfNeeded()
     }
 }
 
@@ -277,12 +237,13 @@ private enum Sheet: Hashable, Identifiable {
     case backgroundRefresh
     case general, graph
     case infoDisplay
-    case alarmsList, alarmSettings
+    case alarmSettings
     case remote
     case importExport
     case calendar, contact
     case advanced
     case viewLog
+    case aggregatedStats
 
     var id: Self { self }
 
@@ -295,7 +256,6 @@ private enum Sheet: Hashable, Identifiable {
         case .general: GeneralSettingsView()
         case .graph: GraphSettingsView()
         case .infoDisplay: InfoDisplaySettingsView(viewModel: .init())
-        case .alarmsList: AlarmListView()
         case .alarmSettings: AlarmSettingsView()
         case .remote: RemoteSettingsView(viewModel: .init())
         case .importExport: ImportExportSettingsView()
@@ -303,10 +263,64 @@ private enum Sheet: Hashable, Identifiable {
         case .contact: ContactSettingsView(viewModel: .init())
         case .advanced: AdvancedSettingsView(viewModel: .init())
         case .viewLog: LogView(viewModel: .init())
+        case .aggregatedStats:
+            AggregatedStatsViewWrapper()
         }
     }
 }
 
+// Helper view to access MainViewController
+struct AggregatedStatsViewWrapper: View {
+    @State private var mainViewController: MainViewController?
+
+    var body: some View {
+        Group {
+            if let mainVC = mainViewController {
+                AggregatedStatsView(viewModel: AggregatedStatsViewModel(mainViewController: mainVC))
+            } else {
+                Text("Loading stats...")
+                    .onAppear {
+                        mainViewController = getMainViewController()
+                    }
+            }
+        }
+    }
+
+    private func getMainViewController() -> MainViewController? {
+        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+              let window = windowScene.windows.first,
+              let rootVC = window.rootViewController
+        else {
+            return nil
+        }
+
+        if let mainVC = rootVC as? MainViewController {
+            return mainVC
+        }
+
+        if let navVC = rootVC as? UINavigationController,
+           let mainVC = navVC.viewControllers.first as? MainViewController
+        {
+            return mainVC
+        }
+
+        if let tabVC = rootVC as? UITabBarController {
+            for vc in tabVC.viewControllers ?? [] {
+                if let mainVC = vc as? MainViewController {
+                    return mainVC
+                }
+                if let navVC = vc as? UINavigationController,
+                   let mainVC = navVC.viewControllers.first as? MainViewController
+                {
+                    return mainVC
+                }
+            }
+        }
+
+        return nil
+    }
+}
+
 // MARK: – UIKit helpers (unchanged)
 
 import UIKit

+ 238 - 108
LoopFollow/Settings/TabCustomizationModal.swift

@@ -3,165 +3,295 @@
 
 import SwiftUI
 
+// Represents either a TabItem or Settings in the drag-and-drop list
+enum TabCustomizationItem: Identifiable, Equatable, Hashable {
+    case tabItem(TabItem)
+    case settings
+
+    var id: String {
+        switch self {
+        case let .tabItem(item): return item.rawValue
+        case .settings: return "settings"
+        }
+    }
+
+    var displayName: String {
+        switch self {
+        case let .tabItem(item): return item.displayName
+        case .settings: return "Menu"
+        }
+    }
+
+    var icon: String {
+        switch self {
+        case let .tabItem(item): return item.icon
+        case .settings: return "line.3.horizontal"
+        }
+    }
+}
+
 struct TabCustomizationModal: View {
     @Binding var isPresented: Bool
     let onApply: () -> Void
 
-    // Local state for editing
-    @State private var alarmsPosition: TabPosition
-    @State private var remotePosition: TabPosition
-    @State private var nightscoutPosition: TabPosition
-    @State private var hasChanges = false
-
-    // Store original values to detect changes
-    private let originalAlarmsPosition: TabPosition
-    private let originalRemotePosition: TabPosition
-    private let originalNightscoutPosition: TabPosition
+    // All items including Settings - top 4 go to tab bar, rest to menu
+    @State private var allItems: [TabCustomizationItem]
+    private let originalItems: [TabCustomizationItem]
 
     init(isPresented: Binding<Bool>, onApply: @escaping () -> Void) {
         _isPresented = isPresented
         self.onApply = onApply
 
-        // Initialize with current values
-        let currentAlarms = Storage.shared.alarmsPosition.value
-        let currentRemote = Storage.shared.remotePosition.value
-        let currentNightscout = Storage.shared.nightscoutPosition.value
+        let sortedTabItems = TabItem.movableItems.sorted { item1, item2 in
+            let pos1 = Storage.shared.position(for: item1).normalized
+            let pos2 = Storage.shared.position(for: item2).normalized
+
+            let isInTabBar1 = TabPosition.customizablePositions.contains(pos1)
+            let isInTabBar2 = TabPosition.customizablePositions.contains(pos2)
 
-        _alarmsPosition = State(initialValue: currentAlarms)
-        _remotePosition = State(initialValue: currentRemote)
-        _nightscoutPosition = State(initialValue: currentNightscout)
+            // Tab bar positions (1-4) come before menu
+            if isInTabBar1, isInTabBar2 {
+                return (pos1.tabIndex ?? 99) < (pos2.tabIndex ?? 99)
+            } else if isInTabBar1 {
+                return true // pos1 is in tab bar, pos2 is in menu
+            } else if isInTabBar2 {
+                return false // pos2 is in tab bar, pos1 is in menu
+            } else {
+                // Both in menu - maintain original order from movableItems
+                let idx1 = TabItem.movableItems.firstIndex(of: item1) ?? 0
+                let idx2 = TabItem.movableItems.firstIndex(of: item2) ?? 0
+                return idx1 < idx2
+            }
+        }
 
-        originalAlarmsPosition = currentAlarms
-        originalRemotePosition = currentRemote
-        originalNightscoutPosition = currentNightscout
+        // Convert to TabCustomizationItem array and add Settings at the start of menu items
+        var items: [TabCustomizationItem] = sortedTabItems.map { .tabItem($0) }
+
+        // Find where menu items start (after position 4)
+        let menuStartIndex = items.firstIndex { item in
+            if case let .tabItem(tabItem) = item {
+                let pos = Storage.shared.position(for: tabItem).normalized
+                return !TabPosition.customizablePositions.contains(pos)
+            }
+            return false
+        } ?? items.count
+
+        // Insert Settings at the start of menu items
+        items.insert(.settings, at: menuStartIndex)
+
+        _allItems = State(initialValue: items)
+        originalItems = items
     }
 
     var body: some View {
-        NavigationView {
-            Form {
-                Section("Tab Positions") {
-                    TabPositionRow(
-                        title: "Alarms",
-                        icon: "alarm",
-                        position: $alarmsPosition,
-                        otherPositions: [remotePosition, nightscoutPosition]
-                    )
-                    .onChange(of: alarmsPosition) { _ in checkForChanges() }
-
-                    TabPositionRow(
-                        title: "Remote",
-                        icon: "antenna.radiowaves.left.and.right",
-                        position: $remotePosition,
-                        otherPositions: [alarmsPosition, nightscoutPosition]
-                    )
-                    .onChange(of: remotePosition) { _ in checkForChanges() }
-
-                    TabPositionRow(
-                        title: "Nightscout",
-                        icon: "safari",
-                        position: $nightscoutPosition,
-                        otherPositions: [alarmsPosition, remotePosition]
-                    )
-                    .onChange(of: nightscoutPosition) { _ in checkForChanges() }
+        NavigationStack {
+            List {
+                // Instructions
+                Section {
+                    VStack(alignment: .leading, spacing: 8) {
+                        Text("Drag to reorder")
+                            .font(.subheadline)
+                            .fontWeight(.medium)
+                        Text("The top 4 items appear in the tab bar. Items 5+ appear in the Menu.")
+                            .font(.caption)
+                            .foregroundColor(.secondary)
+                    }
+                    .padding(.vertical, 4)
                 }
 
+                // All items - Settings appears at position 5 as a divider
                 Section {
-                    Text("• Tab 2 and Tab 4 can each hold one item")
-                        .font(.caption)
-                        .foregroundColor(.secondary)
-                    Text("• Items in 'More Menu' appear under the last tab")
-                        .font(.caption)
-                        .foregroundColor(.secondary)
-                    Text("• Hidden items are not accessible")
-                        .font(.caption)
-                        .foregroundColor(.secondary)
-                }
+                    // Build display list: first 4 TabItems, then Settings, then remaining TabItems
+                    let tabItems = allItems.compactMap { item -> TabItem? in
+                        if case let .tabItem(tabItem) = item { return tabItem }
+                        return nil
+                    }
 
-                if hasChanges {
-                    Section {
-                        Text("Changes will be applied when you tap 'Apply'")
-                            .font(.caption)
-                            .foregroundColor(.orange)
+                    // Display items in order: tab bar items, Settings, menu items
+                    ForEach(Array(allItems.enumerated()), id: \.element) { _, item in
+                        switch item {
+                        case let .tabItem(tabItem):
+                            // Determine if this TabItem is in tab bar or menu
+                            let tabItemIndex = tabItems.firstIndex(of: tabItem) ?? 0
+                            let isInTabBar = tabItemIndex < 4
+
+                            TabItemRow(
+                                item : tabItem,
+                                position: isInTabBar ? tabItemIndex + 1: nil,
+                                isInMenu: !isInTabBar
+                            )
+                        case .settings:
+                            SettingsRow()
+                                .moveDisabled(true)
+                        }
                     }
+                    .onMove { source, destination in
+                        // Check if Settings (at index 4) is being moved - prevent it
+                        if source.contains(4) {
+                            return
+                        }
+
+                        // Get all TabItems (excluding Settings)
+                        var tabItemsOnly: [TabItem] = allItems.compactMap { item -> TabItem? in
+                            if case let .tabItem(tabItem) = item { return tabItem }
+                            return nil
+                        }
+
+                        // Adjust source indices: if any are after Settings (index 4), subtract 1
+                        var adjustedSource = source
+                        if source.contains(where: { $0 > 4 }) {
+                            adjustedSource = IndexSet(source.map { $0 > 4 ? $0 - 1 : $0 })
+                        }
+
+                        // Adjust destination: if it's after Settings (position 5), subtract 1
+                        let adjustedDestination = destination > 4 ? destination - 1 : destination
+
+                        // Move TabItems
+                        tabItemsOnly.move(fromOffsets: adjustedSource, toOffset: adjustedDestination)
+
+                        // Rebuild allItems with Settings at position 5
+                        var newItems: [TabCustomizationItem] = []
+                        for (index, tabItem) in tabItemsOnly.enumerated() {
+                            newItems.append(.tabItem(tabItem))
+                            // Insert Settings after the 4th TabItem (at position 5)
+                            if index == 3 {
+                                newItems.append(.settings)
+                            }
+                        }
+
+                        // If there are fewer than 4 TabItems, add Settings after the last one
+                        if tabItemsOnly.count < 4 {
+                            newItems.append(.settings)
+                        }
+
+                        allItems = newItems
+                    }
+                } header: {
+                    Text("Tab Order")
                 }
             }
+            .listStyle(.insetGrouped)
+            .environment(\.editMode, .constant(.active))
             .navigationTitle("Tab Settings")
             .navigationBarTitleDisplayMode(.inline)
             .toolbar {
                 ToolbarItem(placement: .navigationBarLeading) {
-                    Button("Cancel") {
+                    Button {
+                        allItems = originalItems
                         isPresented = false
+                    } label: {
+                        HStack(spacing: 4) {
+                            Image(systemName: "chevron.left")
+                            Text("Back")
+                        }
                     }
                 }
-
                 ToolbarItem(placement: .navigationBarTrailing) {
-                    Button("Apply") {
-                        applyChanges()
+                    Button {
+                        applyChangesSilently()
+                        onApply()
+                        isPresented = false
+                    } label: {
+                        Image(systemName: "checkmark")
                     }
-                    .fontWeight(.semibold)
-                    .disabled(!hasChanges)
+                    .disabled(allItems == originalItems)
+                    .foregroundColor(.blue)
                 }
             }
         }
         .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
     }
 
-    private func checkForChanges() {
-        hasChanges = alarmsPosition != originalAlarmsPosition ||
-            remotePosition != originalRemotePosition ||
-            nightscoutPosition != originalNightscoutPosition
-    }
-
-    private func applyChanges() {
-        // Save the new positions
-        Storage.shared.alarmsPosition.value = alarmsPosition
-        Storage.shared.remotePosition.value = remotePosition
-        Storage.shared.nightscoutPosition.value = nightscoutPosition
-
-        // Dismiss the modal
-        isPresented = false
+    // MARK: - Actions
 
-        // Call the completion handler after a small delay to ensure modal is dismissed
-        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
-            onApply()
+    private func applyChangesSilently() {
+        // Count only TabItems (not Settings) to determine tab bar positions
+        // First 4 TabItems go to tab bar, rest go to menu
+        var tabItemCount = 0
+        for item in allItems {
+            switch item {
+            case let .tabItem(tabItem):
+                let position: TabPosition
+                if tabItemCount < 4 {
+                    switch tabItemCount {
+                    case 0: position = .position1
+                    case 1: position = .position2
+                    case 2: position = .position3
+                    case 3: position = .position4
+                    default: position = .menu
+                    }
+                } else {
+                    position = .menu
+                }
+                Storage.shared.setPosition(position, for: tabItem)
+                tabItemCount += 1
+            case .settings:
+                break
+            }
         }
+        // Don't call onApply() - let the tab position observers handle the rebuild naturally
     }
 }
 
-struct TabPositionRow: View {
-    let title: String
-    let icon: String
-    @Binding var position: TabPosition
-    let otherPositions: [TabPosition]
-
-    var availablePositions: [TabPosition] {
-        TabPosition.allCases.filter { tabPosition in
-            // Always allow current position and disabled/more
-            if tabPosition == position || tabPosition == .more || tabPosition == .disabled {
-                return true
-            }
-            // Otherwise, only allow if not taken by another position
-            return !otherPositions.contains(tabPosition)
+// MARK: - Row Views
+
+struct SettingsRow: View {
+    var body: some View {
+        HStack {
+            Image(systemName: "line.3.horizontal")
+                .frame(width: 28)
+                .foregroundColor(.secondary)
+            Text("Menu")
+                .foregroundColor(.secondary)
+            Spacer()
+            Text("Tab 5")
+                .font(.caption)
+                .foregroundColor(.white)
+                .padding(.horizontal, 8)
+                .padding(.vertical, 4)
+                .background(Color.gray)
+                .cornerRadius(4)
         }
+        .contentShape(Rectangle())
     }
+}
+
+struct TabItemRow: View {
+    let item: TabItem
+    let position: Int?
+    let isInMenu: Bool
 
     var body: some View {
         HStack {
-            Image(systemName: icon)
-                .frame(width: 30)
-                .foregroundColor(.accentColor)
+            Image(systemName: item.icon)
+                .frame(width: 28)
+                .foregroundColor(isInMenu ? .secondary : .accentColor)
 
-            Text(title)
+            VStack(alignment: .leading, spacing: 2) {
+                Text(item.displayName)
+                    .foregroundColor(isInMenu ? .secondary : .primary)
+            }
 
             Spacer()
 
-            Picker(title, selection: $position) {
-                ForEach(availablePositions, id: \.self) { pos in
-                    Text(pos.displayName).tag(pos)
-                }
+            if let pos = position {
+                Text("Tab \(pos)")
+                    .font(.caption)
+                    .foregroundColor(.white)
+                    .padding(.horizontal, 8)
+                    .padding(.vertical, 4)
+                    .background(Color.accentColor)
+                    .cornerRadius(4)
+            } else {
+                Text("In Menu")
+                    .font(.caption)
+                    .foregroundColor(.orange)
+                    .padding(.horizontal, 8)
+                    .padding(.vertical, 4)
+                    .background(Color.orange.opacity(0.15))
+                    .cornerRadius(4)
             }
-            .pickerStyle(.menu)
-            .labelsHidden()
         }
+        .contentShape(Rectangle())
     }
 }

+ 70 - 0
LoopFollow/Stats/AGP/AGPCalculator.swift

@@ -0,0 +1,70 @@
+// LoopFollow
+// AGPCalculator.swift
+
+import Foundation
+
+class AGPCalculator {
+    static func calculate(bgData: [ShareGlucoseData]) -> [AGPDataPoint] {
+        guard !bgData.isEmpty else { return [] }
+
+        var hourData: [Int: [Double]] = [:]
+        let calendar = dateTimeUtils.displayCalendar()
+
+        for reading in bgData {
+            let date = Date(timeIntervalSince1970: reading.date)
+            let components = calendar.dateComponents([.hour], from: date)
+            let hour = components.hour ?? 0
+
+            let glucose = Double(reading.sgv)
+            let glucoseMgdL = Storage.shared.units.value == "mg/dL" ? glucose : glucose * GlucoseConversion.mmolToMgDl
+
+            if hourData[hour] == nil {
+                hourData[hour] = []
+            }
+            hourData[hour]?.append(glucoseMgdL)
+        }
+
+        var agpPoints: [AGPDataPoint] = []
+        for hour in 0 ..< 24 {
+            guard let values = hourData[hour], !values.isEmpty else { continue }
+
+            let sorted = values.sorted()
+            let p5 = PercentileCalculator.percentile(sorted, p: 0.05)
+            let p25 = PercentileCalculator.percentile(sorted, p: 0.25)
+            let p50 = PercentileCalculator.percentile(sorted, p: 0.50)
+            let p75 = PercentileCalculator.percentile(sorted, p: 0.75)
+            let p95 = PercentileCalculator.percentile(sorted, p: 0.95)
+
+            let convert: (Double) -> Double = { value in
+                Storage.shared.units.value == "mg/dL" ? value : value * GlucoseConversion.mgDlToMmolL
+            }
+
+            let minutesSinceMidnight = hour * 60
+
+            agpPoints.append(AGPDataPoint(
+                timeOfDay: minutesSinceMidnight,
+                p5: convert(p5),
+                p25: convert(p25),
+                p50: convert(p50),
+                p75: convert(p75),
+                p95: convert(p95)
+            ))
+        }
+
+        return agpPoints.sorted { $0.timeOfDay < $1.timeOfDay }
+    }
+}
+
+class PercentileCalculator {
+    static func percentile(_ sorted: [Double], p: Double) -> Double {
+        guard !sorted.isEmpty else { return 0.0 }
+        if sorted.count == 1 { return sorted[0] }
+
+        let index = p * Double(sorted.count - 1)
+        let lower = Int(index.rounded(.down))
+        let upper = min(lower + 1, sorted.count - 1)
+        let weight = index - Double(lower)
+
+        return sorted[lower] * (1.0 - weight) + sorted[upper] * weight
+    }
+}

+ 13 - 0
LoopFollow/Stats/AGP/AGPDataPoint.swift

@@ -0,0 +1,13 @@
+// LoopFollow
+// AGPDataPoint.swift
+
+import Foundation
+
+struct AGPDataPoint {
+    let timeOfDay: Int
+    let p5: Double
+    let p25: Double
+    let p50: Double
+    let p75: Double
+    let p95: Double
+}

+ 144 - 0
LoopFollow/Stats/AGP/AGPGraphView.swift

@@ -0,0 +1,144 @@
+// LoopFollow
+// AGPGraphView.swift
+
+import Charts
+import SwiftUI
+
+struct AGPGraphView: UIViewRepresentable {
+    let agpData: [AGPDataPoint]
+
+    func makeCoordinator() -> Coordinator {
+        Coordinator()
+    }
+
+    func makeUIView(context _: Context) -> UIView {
+        let containerView = NonInteractiveContainerView()
+        containerView.backgroundColor = .systemBackground
+
+        let chartView = LineChartView()
+        chartView.rightAxis.enabled = true
+        chartView.leftAxis.enabled = false
+        chartView.xAxis.labelPosition = .bottom
+        chartView.rightAxis.drawGridLinesEnabled = false
+        chartView.leftAxis.drawGridLinesEnabled = false
+        chartView.xAxis.drawGridLinesEnabled = false
+        chartView.rightAxis.valueFormatter = ChartYMMOLValueFormatter()
+        chartView.legend.enabled = false
+        chartView.chartDescription.enabled = false
+        chartView.isUserInteractionEnabled = false
+
+        containerView.addSubview(chartView)
+        chartView.translatesAutoresizingMaskIntoConstraints = false
+        NSLayoutConstraint.activate([
+            chartView.topAnchor.constraint(equalTo: containerView.topAnchor),
+            chartView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
+            chartView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
+            chartView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
+        ])
+
+        return containerView
+    }
+
+    class Coordinator {}
+
+    func updateUIView(_ containerView: UIView, context _: Context) {
+        guard let chartView = containerView.subviews.first as? LineChartView else { return }
+        guard !agpData.isEmpty else { return }
+        var p5Entries: [ChartDataEntry] = []
+        var p25Entries: [ChartDataEntry] = []
+        var p50Entries: [ChartDataEntry] = []
+        var p75Entries: [ChartDataEntry] = []
+        var p95Entries: [ChartDataEntry] = []
+
+        for point in agpData {
+            let x = Double(point.timeOfDay) / 60.0
+            p5Entries.append(ChartDataEntry(x: x, y: point.p5))
+            p25Entries.append(ChartDataEntry(x: x, y: point.p25))
+            p50Entries.append(ChartDataEntry(x: x, y: point.p50))
+            p75Entries.append(ChartDataEntry(x: x, y: point.p75))
+            p95Entries.append(ChartDataEntry(x: x, y: point.p95))
+        }
+
+        let sortedP5 = p5Entries.sorted { $0.x < $1.x }
+        let sortedP25 = p25Entries.sorted { $0.x < $1.x }
+        let sortedP50 = p50Entries.sorted { $0.x < $1.x }
+        let sortedP75 = p75Entries.sorted { $0.x < $1.x }
+        let sortedP95 = p95Entries.sorted { $0.x < $1.x }
+
+        guard !sortedP5.isEmpty, !sortedP25.isEmpty, !sortedP50.isEmpty,
+              !sortedP75.isEmpty, !sortedP95.isEmpty
+        else {
+            return
+        }
+        let p5DataSet = LineChartDataSet(entries: sortedP5, label: "5th")
+        p5DataSet.colors = [NSUIColor.systemGray.withAlphaComponent(0.6)]
+        p5DataSet.lineWidth = 1.5
+        p5DataSet.drawCirclesEnabled = false
+        p5DataSet.drawValuesEnabled = false
+        p5DataSet.drawFilledEnabled = false
+        p5DataSet.mode = .linear
+
+        let p25DataSet = LineChartDataSet(entries: sortedP25, label: "25th")
+        p25DataSet.colors = [NSUIColor.systemBlue.withAlphaComponent(0.7)]
+        p25DataSet.lineWidth = 1.5
+        p25DataSet.drawCirclesEnabled = false
+        p25DataSet.drawValuesEnabled = false
+        p25DataSet.drawFilledEnabled = false
+        p25DataSet.mode = .linear
+
+        let p50DataSet = LineChartDataSet(entries: sortedP50, label: "Median")
+        p50DataSet.colors = [NSUIColor.systemBlue]
+        p50DataSet.lineWidth = 3
+        p50DataSet.drawCirclesEnabled = false
+        p50DataSet.drawValuesEnabled = false
+        p50DataSet.drawFilledEnabled = false
+        p50DataSet.mode = .linear
+
+        let p75DataSet = LineChartDataSet(entries: sortedP75, label: "75th")
+        p75DataSet.colors = [NSUIColor.systemBlue.withAlphaComponent(0.7)]
+        p75DataSet.lineWidth = 1.5
+        p75DataSet.drawCirclesEnabled = false
+        p75DataSet.drawValuesEnabled = false
+        p75DataSet.drawFilledEnabled = false
+        p75DataSet.mode = .linear
+
+        let p95DataSet = LineChartDataSet(entries: sortedP95, label: "95th")
+        p95DataSet.colors = [NSUIColor.systemGray.withAlphaComponent(0.6)]
+        p95DataSet.lineWidth = 1.5
+        p95DataSet.drawCirclesEnabled = false
+        p95DataSet.drawValuesEnabled = false
+        p95DataSet.drawFilledEnabled = false
+        p95DataSet.mode = .linear
+        let maxY = max(sortedP95.map { $0.y }.max() ?? 300, 300) + 10
+        let hourMinY = min(sortedP5.map { $0.y }.min() ?? 0, 0) - 10
+
+        var hourLines: [ChartDataEntry] = []
+        for hour in 0 ... 24 {
+            let x = Double(hour)
+            hourLines.append(ChartDataEntry(x: x, y: hourMinY))
+            hourLines.append(ChartDataEntry(x: x, y: maxY))
+            if hour < 24 {
+                hourLines.append(ChartDataEntry(x: x + 0.0001, y: hourMinY))
+            }
+        }
+
+        let hourLinesDataSet = LineChartDataSet(entries: hourLines, label: "Hours")
+        hourLinesDataSet.colors = [NSUIColor.label.withAlphaComponent(0.3)]
+        hourLinesDataSet.lineWidth = 1
+        hourLinesDataSet.drawCirclesEnabled = false
+        hourLinesDataSet.drawValuesEnabled = false
+        hourLinesDataSet.drawFilledEnabled = false
+
+        let data = LineChartData()
+        data.append(p5DataSet)
+        data.append(p25DataSet)
+        data.append(p50DataSet)
+        data.append(p75DataSet)
+        data.append(p95DataSet)
+        data.append(hourLinesDataSet)
+
+        chartView.data = data
+        chartView.notifyDataSetChanged()
+        chartView.setNeedsDisplay()
+    }
+}

+ 50 - 0
LoopFollow/Stats/AGP/AGPView.swift

@@ -0,0 +1,50 @@
+// LoopFollow
+// AGPView.swift
+
+import SwiftUI
+
+struct AGPView: View {
+    @ObservedObject var viewModel: AGPViewModel
+
+    var body: some View {
+        if !viewModel.agpData.isEmpty {
+            VStack(alignment: .leading, spacing: 8) {
+                Text("Ambulatory Glucose Profile (AGP)")
+                    .font(.caption)
+                    .foregroundColor(.secondary)
+
+                AGPGraphView(agpData: viewModel.agpData)
+                    .frame(height: 200)
+                    .allowsHitTesting(false)
+                    .clipped()
+
+                // Legend
+                HStack(spacing: 16) {
+                    LegendItem(color: .gray.opacity(0.6), label: "5th-95th")
+                    LegendItem(color: .blue.opacity(0.7), label: "25th-75th")
+                    LegendItem(color: .blue, label: "Median")
+                }
+                .font(.caption2)
+            }
+            .frame(maxWidth: .infinity, alignment: .leading)
+            .padding()
+            .background(Color(.systemGray6))
+            .cornerRadius(12)
+        }
+    }
+}
+
+struct LegendItem: View {
+    let color: Color
+    let label: String
+
+    var body: some View {
+        HStack(spacing: 4) {
+            Rectangle()
+                .fill(color)
+                .frame(width: 12, height: 12)
+            Text(label)
+                .foregroundColor(.secondary)
+        }
+    }
+}

+ 25 - 0
LoopFollow/Stats/AGP/AGPViewModel.swift

@@ -0,0 +1,25 @@
+// LoopFollow
+// AGPViewModel.swift
+
+import Combine
+import Foundation
+
+class AGPViewModel: ObservableObject {
+    @Published var agpData: [AGPDataPoint] = []
+
+    private let dataService: StatsDataService
+
+    init(dataService: StatsDataService) {
+        self.dataService = dataService
+        calculateAGP()
+    }
+
+    func calculateAGP() {
+        let bgData = dataService.getBGData()
+        agpData = AGPCalculator.calculate(bgData: bgData)
+    }
+
+    func clearStats() {
+        agpData = []
+    }
+}

+ 424 - 0
LoopFollow/Stats/AggregatedStatsView.swift

@@ -0,0 +1,424 @@
+// LoopFollow
+// AggregatedStatsView.swift
+
+import SwiftUI
+import UIKit
+
+struct AggregatedStatsView: View {
+    @ObservedObject var viewModel: AggregatedStatsViewModel
+    @Environment(\.dismiss) var dismiss
+    @State private var showGMI: Bool
+    @State private var showStdDev: Bool
+    @State private var startDate: Date
+    @State private var endDate: Date
+    @State private var isLoadingData = false
+    @State private var showLoadingMessage = false
+    @State private var loadingError = false
+    @State private var loadingTimer: Timer?
+    @State private var timeoutTimer: Timer?
+
+    init(viewModel: AggregatedStatsViewModel) {
+        self.viewModel = viewModel
+        _showGMI = State(initialValue: Storage.shared.showGMI.value)
+        _showStdDev = State(initialValue: Storage.shared.showStdDev.value)
+
+        let calendar = dateTimeUtils.displayCalendar()
+        let startOfToday = calendar.startOfDay(for: Date())
+        let end = calendar.date(byAdding: .second, value: -1, to: startOfToday) ?? Date()
+        let endDay = calendar.startOfDay(for: end)
+        let startDay = calendar.date(byAdding: .day, value: -7, to: endDay) ?? endDay
+        let start = calendar.startOfDay(for: startDay)
+        _startDate = State(initialValue: start)
+        _endDate = State(initialValue: end)
+    }
+
+    var body: some View {
+        ScrollView {
+            VStack(spacing: 20) {
+                VStack(spacing: 12) {
+                    Text("Statistics")
+                        .font(.largeTitle)
+                        .fontWeight(.bold)
+
+                    DateRangePicker(
+                        startDate: $startDate,
+                        endDate: $endDate,
+                        availability: viewModel.dataAvailability,
+                        onDateChange: {
+                            loadingError = false
+                            isLoadingData = true
+                            viewModel.updateDateRange(start: startDate, end: endDate) {
+                                isLoadingData = false
+                            }
+                        }
+                    )
+                    .padding(.horizontal)
+                }
+                .padding(.top)
+
+                if loadingError {
+                    VStack(spacing: 8) {
+                        Text("#WeAreNotWaitingAnymore")
+                            .font(.subheadline)
+                            .fontWeight(.medium)
+                            .foregroundColor(.orange)
+                        Text("...because the data could not be loaded")
+                            .font(.caption)
+                            .foregroundColor(.secondary)
+                    }
+                    .padding()
+                } else if isLoadingData {
+                    VStack(spacing: 12) {
+                        ProgressView("Loading data...")
+                        if showLoadingMessage {
+                            Text("#WeAreWaitingForData")
+                                .font(.caption)
+                                .foregroundColor(.secondary)
+                        }
+                    }
+                    .padding()
+                }
+
+                StatsGridView(
+                    simpleStats: viewModel.simpleStats,
+                    showGMI: $showGMI,
+                    showStdDev: $showStdDev
+                )
+                .padding(.horizontal)
+                .opacity(isLoadingData ? 0.4 : 1.0)
+                .disabled(isLoadingData)
+
+                AGPView(viewModel: viewModel.agpStats)
+                    .padding(.horizontal)
+                    .opacity(isLoadingData ? 0.4 : 1.0)
+
+                TIRView(viewModel: viewModel.tirStats)
+                    .padding(.horizontal)
+                    .opacity(isLoadingData ? 0.4 : 1.0)
+
+                GRIView(viewModel: viewModel.griStats)
+                    .padding(.horizontal)
+                    .opacity(isLoadingData ? 0.4 : 1.0)
+            }
+            .padding(.bottom)
+            .frame(maxWidth: .infinity)
+        }
+        .navigationBarTitleDisplayMode(.inline)
+        .toolbar {
+            ToolbarItem(placement: .navigationBarLeading) {
+                Button("Refresh") {
+                    loadingError = false
+                    isLoadingData = true
+                    viewModel.updateDateRange(start: startDate, end: endDate) {
+                        isLoadingData = false
+                    }
+                }
+            }
+        }
+        .onAppear {
+            loadingError = false
+            isLoadingData = true
+            viewModel.updateDateRange(start: startDate, end: endDate) {
+                isLoadingData = false
+            }
+        }
+        .onChange(of: isLoadingData) { newValue in
+            if newValue {
+                showLoadingMessage = false
+                loadingError = false
+
+                // Show "still waiting" message after 3 seconds
+                loadingTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in
+                    showLoadingMessage = true
+                }
+
+                // Timeout after 30 seconds
+                timeoutTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: false) { _ in
+                    if self.isLoadingData {
+                        self.isLoadingData = false
+                        self.loadingError = true
+                        self.viewModel.clearAllStats()
+                    }
+                }
+            } else {
+                loadingTimer?.invalidate()
+                loadingTimer = nil
+                timeoutTimer?.invalidate()
+                timeoutTimer = nil
+                showLoadingMessage = false
+            }
+        }
+        .onDisappear {
+            loadingTimer?.invalidate()
+            loadingTimer = nil
+            timeoutTimer?.invalidate()
+            timeoutTimer = nil
+        }
+        .onChange(of: showGMI) { newValue in
+            Storage.shared.showGMI.value = newValue
+        }
+        .onChange(of: showStdDev) { newValue in
+            Storage.shared.showStdDev.value = newValue
+        }
+    }
+}
+
+struct StatCard: View {
+    let title: String
+    let value: String
+    let unit: String?
+    let color: Color
+    var isInteractive: Bool = false
+
+    var body: some View {
+        ZStack(alignment: .topTrailing) {
+            VStack(alignment: .leading, spacing: 8) {
+                Text(title)
+                    .font(.caption)
+                    .foregroundColor(.secondary)
+
+                HStack(alignment: .firstTextBaseline, spacing: 4) {
+                    Text(value)
+                        .font(.title2)
+                        .fontWeight(.semibold)
+                        .foregroundColor(color)
+
+                    if let unit = unit {
+                        Text(unit)
+                            .font(.caption)
+                            .foregroundColor(.secondary)
+                    }
+                }
+            }
+            .frame(maxWidth: .infinity, alignment: .leading)
+            .padding()
+
+            if isInteractive {
+                Image(systemName: "chevron.up.chevron.down")
+                    .font(.caption2)
+                    .foregroundColor(.secondary.opacity(0.5))
+                    .padding(8)
+            }
+        }
+        .background(Color(.systemGray6))
+        .cornerRadius(12)
+    }
+}
+
+struct InsulinTotalsCard: View {
+    let totalNegativeBasal: Double?
+    let totalPositiveBasal: Double?
+    let programmedBasal: Double?
+    let actualBasal: Double?
+    let avgBolus: Double?
+    let totalDailyDose: Double?
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            Text("Insulin Totals")
+                .font(.caption)
+                .foregroundColor(.secondary)
+
+            VStack(spacing: 10) {
+                metricRow(title: "Programmed Basal", value: programmedBasal, color: .indigo)
+                metricRow(title: "Total Negative Basal", value: totalNegativeBasal, color: .red)
+                metricRow(title: "Total Positive Basal", value: totalPositiveBasal, color: .green)
+                metricRow(title: "Actual Basal", value: actualBasal, color: .indigo)
+                metricRow(title: "Avg Bolus", value: avgBolus, color: .purple)
+                metricRow(title: "Total Daily Dose", value: totalDailyDose, color: .pink)
+            }
+        }
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .padding()
+        .background(Color(.systemGray6))
+        .cornerRadius(12)
+    }
+
+    @ViewBuilder
+    private func metricRow(title: String, value: Double?, color: Color) -> some View {
+        HStack(alignment: .firstTextBaseline) {
+            Text(title)
+                .font(.subheadline)
+                .foregroundColor(.secondary)
+
+            Spacer()
+
+            Text(formatBasal(value))
+                .font(.headline)
+                .fontWeight(.semibold)
+                .foregroundColor(color)
+                + Text(" U")
+                .font(.caption)
+                .foregroundColor(.secondary)
+        }
+    }
+
+    private func formatBasal(_ value: Double?) -> String {
+        guard let value = value else { return "---" }
+        return String(format: "%.2f", value)
+    }
+}
+
+struct StatsGridView: View {
+    @ObservedObject var simpleStats: SimpleStatsViewModel
+    @Binding var showGMI: Bool
+    @Binding var showStdDev: Bool
+    @State private var showInsulinBreakdown = false
+
+    private var hasInsulinData: Bool {
+        simpleStats.totalDailyDose != nil || simpleStats.avgBolus != nil || simpleStats.actualBasal != nil || simpleStats.programmedBasal != nil
+    }
+
+    private var hasCarbData: Bool {
+        simpleStats.avgCarbs != nil
+    }
+
+    var body: some View {
+        VStack(spacing: 16) {
+            LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
+                Button(action: {
+                    showGMI.toggle()
+                }) {
+                    StatCard(
+                        title: showGMI ? "GMI" : "eHbA1c",
+                        value: showGMI ? formatGMI(simpleStats.gmi) : formatEhbA1c(simpleStats.avgGlucose),
+                        unit: showGMI ? "%" : (Storage.shared.units.value == "mg/dL" ? "%" : "mmol/mol"),
+                        color: .blue,
+                        isInteractive: true
+                    )
+                }
+                .buttonStyle(PlainButtonStyle())
+
+                StatCard(
+                    title: "Avg Glucose",
+                    value: formatGlucose(simpleStats.avgGlucose),
+                    unit: Storage.shared.units.value,
+                    color: Color(red: 0.20, green: 0.99, blue: 0.70)
+                )
+
+                Button(action: {
+                    showStdDev.toggle()
+                }) {
+                    StatCard(
+                        title: showStdDev ? "Std Deviation" : "CV",
+                        value: showStdDev ? formatStdDev(simpleStats.stdDeviation) : formatCV(simpleStats.coefficientOfVariation),
+                        unit: showStdDev ? Storage.shared.units.value : "%",
+                        color: .orange,
+                        isInteractive: true
+                    )
+                }
+                .buttonStyle(PlainButtonStyle())
+
+                if hasCarbData {
+                    StatCard(
+                        title: "Avg Carbs",
+                        value: formatCarbs(simpleStats.avgCarbs),
+                        unit: "g/day",
+                        color: .yellow
+                    )
+                }
+
+                if hasInsulinData {
+                    StatCard(
+                        title: "Programmed Basal",
+                        value: formatInsulin(simpleStats.programmedBasal),
+                        unit: "U/day",
+                        color: .indigo
+                    )
+                    .onTapGesture(count: 3) {
+                        showInsulinBreakdown = true
+                    }
+
+                    StatCard(
+                        title: "Avg Bolus",
+                        value: formatInsulin(simpleStats.avgBolus),
+                        unit: "U/day",
+                        color: .purple
+                    )
+                    .onTapGesture(count: 3) {
+                        showInsulinBreakdown = true
+                    }
+
+                    StatCard(
+                        title: "Total Daily Dose",
+                        value: formatInsulin(simpleStats.totalDailyDose),
+                        unit: "U",
+                        color: .pink
+                    )
+                    .onTapGesture(count: 3) {
+                        showInsulinBreakdown = true
+                    }
+                }
+            }
+
+            if hasInsulinData && showInsulinBreakdown {
+                InsulinTotalsCard(
+                    totalNegativeBasal: simpleStats.totalNegativeBasal,
+                    totalPositiveBasal: simpleStats.totalPositiveBasal,
+                    programmedBasal: simpleStats.programmedBasal,
+                    actualBasal: simpleStats.actualBasal,
+                    avgBolus: simpleStats.avgBolus,
+                    totalDailyDose: simpleStats.totalDailyDose
+                )
+            }
+        }
+    }
+
+    private func formatGMI(_ value: Double?) -> String {
+        guard let value = value else { return "---" }
+        return String(format: "%.1f", value)
+    }
+
+    private func formatEhbA1c(_ avgGlucose: Double?) -> String {
+        guard let avgGlucose = avgGlucose else { return "---" }
+
+        let avgGlucoseMgdL: Double
+        if Storage.shared.units.value == "mg/dL" {
+            avgGlucoseMgdL = avgGlucose
+        } else {
+            avgGlucoseMgdL = avgGlucose * 18.0182
+        }
+
+        let ehba1cPercent = (avgGlucoseMgdL + 46.7) / 28.7
+
+        if Storage.shared.units.value == "mg/dL" {
+            return String(format: "%.1f", ehba1cPercent)
+        } else {
+            let ehba1cMmolMol = (ehba1cPercent - 2.15) * 10.929
+            return String(format: "%.0f", ehba1cMmolMol)
+        }
+    }
+
+    private func formatGlucose(_ value: Double?) -> String {
+        guard let value = value else { return "---" }
+        if Storage.shared.units.value == "mg/dL" {
+            return String(format: "%.0f", value)
+        } else {
+            return String(format: "%.1f", value)
+        }
+    }
+
+    private func formatStdDev(_ value: Double?) -> String {
+        guard let value = value else { return "---" }
+        if Storage.shared.units.value == "mg/dL" {
+            return String(format: "%.0f", value)
+        } else {
+            return String(format: "%.1f", value)
+        }
+    }
+
+    private func formatInsulin(_ value: Double?) -> String {
+        guard let value = value else { return "---" }
+        return String(format: "%.2f", value)
+    }
+
+    private func formatCarbs(_ value: Double?) -> String {
+        guard let value = value else { return "---" }
+        return String(format: "%.0f", value)
+    }
+
+    private func formatCV(_ value: Double?) -> String {
+        guard let value = value else { return "---" }
+        return String(format: "%.1f", value)
+    }
+}

+ 72 - 0
LoopFollow/Stats/AggregatedStatsViewModel.swift

@@ -0,0 +1,72 @@
+// LoopFollow
+// AggregatedStatsViewModel.swift
+
+import Combine
+import Foundation
+
+class AggregatedStatsViewModel: ObservableObject {
+    var simpleStats: SimpleStatsViewModel
+    var agpStats: AGPViewModel
+    var griStats: GRIViewModel
+    var tirStats: TIRViewModel
+
+    let dataService: StatsDataService
+
+    @Published var dataAvailability: DataAvailabilityInfo?
+
+    init(mainViewController: MainViewController?) {
+        dataService = StatsDataService(mainViewController: mainViewController)
+        simpleStats = SimpleStatsViewModel(dataService: dataService)
+        agpStats = AGPViewModel(dataService: dataService)
+        griStats = GRIViewModel(dataService: dataService)
+        tirStats = TIRViewModel(dataService: dataService)
+    }
+
+    func calculateStats() {
+        simpleStats.calculateStats()
+        agpStats.calculateAGP()
+        griStats.calculateGRI()
+        tirStats.calculateTIR()
+        dataAvailability = dataService.getDataAvailability()
+    }
+
+    func clearAllStats() {
+        simpleStats.clearStats()
+        agpStats.clearStats()
+        griStats.clearStats()
+        tirStats.clearStats()
+        dataAvailability = nil
+    }
+
+    func updatePeriod(_ days: Int, completion: @escaping () -> Void = {}) {
+        let endDate = Date()
+        let startDate = dateTimeUtils.displayCalendar().date(byAdding: .day, value: -days, to: endDate) ?? endDate
+        updateDateRange(start: startDate, end: endDate, completion: completion)
+    }
+
+    func updateDateRange(start: Date, end: Date, completion: @escaping () -> Void = {}) {
+        dataService.updateDateRange(start: start, end: end)
+        dataService.ensureDataAvailable(
+            onProgress: {},
+            completion: {
+                self.calculateStats()
+                completion()
+            }
+        )
+    }
+
+    var gmi: Double? { simpleStats.gmi }
+    var avgGlucose: Double? { simpleStats.avgGlucose }
+    var stdDeviation: Double? { simpleStats.stdDeviation }
+    var coefficientOfVariation: Double? { simpleStats.coefficientOfVariation }
+    var totalDailyDose: Double? { simpleStats.totalDailyDose }
+    var programmedBasal: Double? { simpleStats.programmedBasal }
+    var actualBasal: Double? { simpleStats.actualBasal }
+    var avgBolus: Double? { simpleStats.avgBolus }
+    var avgCarbs: Double? { simpleStats.avgCarbs }
+    var agpData: [AGPDataPoint] { agpStats.agpData }
+    var gri: Double? { griStats.gri }
+    var griHypoComponent: Double? { griStats.griHypoComponent }
+    var griHyperComponent: Double? { griStats.griHyperComponent }
+    var griDataPoints: [(date: Date, value: Double)] { griStats.griDataPoints }
+}

+ 25 - 0
LoopFollow/Stats/ChartContainerView.swift

@@ -0,0 +1,25 @@
+// LoopFollow
+// ChartContainerView.swift
+
+import Charts
+import UIKit
+
+class NonInteractiveContainerView: UIView {
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        setup()
+    }
+
+    required init?(coder: NSCoder) {
+        super.init(coder: coder)
+        setup()
+    }
+
+    private func setup() {
+        isUserInteractionEnabled = false
+    }
+
+    override func hitTest(_: CGPoint, with _: UIEvent?) -> UIView? {
+        return nil
+    }
+}

+ 117 - 0
LoopFollow/Stats/DataAvailabilityCalculator.swift

@@ -0,0 +1,117 @@
+// LoopFollow
+// DataAvailabilityCalculator.swift
+
+import Foundation
+
+struct DataAvailabilityInfo {
+    let totalExpectedReadings: Int
+    let actualReadings: Int
+    let coveragePercentage: Double
+    let missingIntervals: Int
+    let dataQuality: DataQuality
+
+    enum DataQuality {
+        case excellent // >= 95%
+        case good // >= 70%
+        case fair // >= 50%
+        case poor // < 50%
+
+        var color: String {
+            switch self {
+            case .excellent: return "green"
+            case .good: return "green"
+            case .fair: return "orange"
+            case .poor: return "red"
+            }
+        }
+
+        var description: String {
+            switch self {
+            case .excellent: return "Excellent"
+            case .good: return "Good"
+            case .fair: return "Fair"
+            case .poor: return "Poor"
+            }
+        }
+    }
+
+    var displayText: String {
+        return String(format: "%.1f%% (%d/%d readings)",
+                      coveragePercentage,
+                      actualReadings,
+                      totalExpectedReadings)
+    }
+}
+
+class DataAvailabilityCalculator {
+    /// Calculates data availability based on expected CGM readings every 5 minutes
+    /// - Parameters:
+    ///   - bgData: Array of glucose readings
+    ///   - startDate: Start of the analysis period
+    ///   - endDate: End of the analysis period
+    /// - Returns: DataAvailabilityInfo with coverage statistics
+    static func calculateAvailability(
+        bgData: [ShareGlucoseData],
+        startDate: Date,
+        endDate: Date
+    ) -> DataAvailabilityInfo {
+        let intervalMinutes = 5.0
+        let totalMinutes = endDate.timeIntervalSince(startDate) / 60.0
+        let expectedReadings = Int(totalMinutes / intervalMinutes)
+
+        let startTimestamp = startDate.timeIntervalSince1970
+        let endTimestamp = endDate.timeIntervalSince1970
+
+        let relevantData = bgData.filter { reading in
+            reading.date >= startTimestamp && reading.date <= endTimestamp
+        }.sorted { $0.date < $1.date }
+
+        guard !relevantData.isEmpty else {
+            return DataAvailabilityInfo(
+                totalExpectedReadings: max(expectedReadings, 0),
+                actualReadings: 0,
+                coveragePercentage: 0.0,
+                missingIntervals: max(expectedReadings, 0),
+                dataQuality: .poor
+            )
+        }
+
+        var coveredIntervals = Set<Int>()
+
+        for reading in relevantData {
+            let readingTime = Date(timeIntervalSince1970: reading.date)
+            let minutesSinceStart = readingTime.timeIntervalSince(startDate) / 60.0
+            let intervalIndex = Int(minutesSinceStart / intervalMinutes)
+
+            if intervalIndex >= 0 && intervalIndex < expectedReadings {
+                coveredIntervals.insert(intervalIndex)
+            }
+        }
+
+        let actualCoveredIntervals = coveredIntervals.count
+        let coveragePercentage = expectedReadings > 0
+            ? (Double(actualCoveredIntervals) / Double(expectedReadings)) * 100.0
+            : 0.0
+
+        let missingIntervals = expectedReadings - actualCoveredIntervals
+
+        let quality: DataAvailabilityInfo.DataQuality
+        if coveragePercentage >= 95.0 {
+            quality = .excellent
+        } else if coveragePercentage >= 70.0 {
+            quality = .good
+        } else if coveragePercentage >= 50.0 {
+            quality = .fair
+        } else {
+            quality = .poor
+        }
+
+        return DataAvailabilityInfo(
+            totalExpectedReadings: expectedReadings,
+            actualReadings: actualCoveredIntervals,
+            coveragePercentage: coveragePercentage,
+            missingIntervals: missingIntervals,
+            dataQuality: quality
+        )
+    }
+}

+ 114 - 0
LoopFollow/Stats/DataAvailabilityView.swift

@@ -0,0 +1,114 @@
+// LoopFollow
+// DataAvailabilityView.swift
+
+import SwiftUI
+
+struct DataAvailabilityView: View {
+    let availability: DataAvailabilityInfo
+    var compact: Bool = false
+
+    var body: some View {
+        if compact {
+            compactView
+        } else {
+            fullView
+        }
+    }
+
+    private var compactView: some View {
+        HStack(spacing: 8) {
+            Image(systemName: statusIcon)
+                .font(.caption)
+                .foregroundColor(statusColor)
+
+            Text(availability.displayText)
+                .font(.caption)
+                .foregroundColor(.secondary)
+
+            Text(availability.dataQuality.description)
+                .font(.caption)
+                .fontWeight(.medium)
+                .foregroundColor(statusColor)
+        }
+        .padding(.horizontal, 12)
+        .padding(.vertical, 6)
+        .background(backgroundColor.opacity(0.5))
+        .cornerRadius(8)
+    }
+
+    private var fullView: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            HStack {
+                Image(systemName: statusIcon)
+                    .foregroundColor(statusColor)
+
+                VStack(alignment: .leading, spacing: 2) {
+                    Text("Data Availability")
+                        .font(.caption)
+                        .foregroundColor(.secondary)
+
+                    Text(availability.displayText)
+                        .font(.subheadline)
+                        .fontWeight(.medium)
+                }
+
+                Spacer()
+
+                VStack(alignment: .trailing, spacing: 2) {
+                    Text(availability.dataQuality.description)
+                        .font(.caption)
+                        .fontWeight(.semibold)
+                        .foregroundColor(statusColor)
+
+                    if availability.missingIntervals > 0 {
+                        Text("\(availability.missingIntervals) gaps")
+                            .font(.caption2)
+                            .foregroundColor(.secondary)
+                    }
+                }
+            }
+            .padding()
+            .background(backgroundColor)
+            .cornerRadius(12)
+        }
+    }
+
+    private var statusIcon: String {
+        switch availability.dataQuality {
+        case .excellent:
+            return "checkmark.circle.fill"
+        case .good:
+            return "checkmark.circle"
+        case .fair:
+            return "exclamationmark.triangle"
+        case .poor:
+            return "xmark.circle"
+        }
+    }
+
+    private var statusColor: Color {
+        switch availability.dataQuality {
+        case .excellent:
+            return .green
+        case .good:
+            return .blue
+        case .fair:
+            return .orange
+        case .poor:
+            return .red
+        }
+    }
+
+    private var backgroundColor: Color {
+        switch availability.dataQuality {
+        case .excellent:
+            return Color.green.opacity(0.1)
+        case .good:
+            return Color.blue.opacity(0.1)
+        case .fair:
+            return Color.orange.opacity(0.1)
+        case .poor:
+            return Color.red.opacity(0.1)
+        }
+    }
+}

+ 294 - 0
LoopFollow/Stats/DateRangePicker.swift

@@ -0,0 +1,294 @@
+// LoopFollow
+// DateRangePicker.swift
+
+import SwiftUI
+
+struct DateRangePicker: View {
+    @Binding var startDate: Date
+    @Binding var endDate: Date
+    var availability: DataAvailabilityInfo?
+    var onDateChange: () -> Void
+
+    @State private var isExpanded = false
+    @State private var showStartDatePicker = false
+    @State private var showEndDatePicker = false
+
+    private var dateFormatter: DateFormatter {
+        let formatter = DateFormatter()
+        formatter.dateStyle = .medium
+        formatter.timeStyle = .none
+        dateTimeUtils.applyDisplayTimeZone(to: formatter)
+        return formatter
+    }
+
+    private var compactDateFormatter: DateFormatter {
+        let formatter = DateFormatter()
+        formatter.dateStyle = .short
+        formatter.timeStyle = .none
+        dateTimeUtils.applyDisplayTimeZone(to: formatter)
+        return formatter
+    }
+
+    private var dayCount: Int {
+        let calendar = dateTimeUtils.displayCalendar()
+        return calendar.dateComponents([.day], from: startDate, to: endDate).day ?? 0
+    }
+
+    private var lastFullDay: Date {
+        let calendar = dateTimeUtils.displayCalendar()
+        let startOfToday = calendar.startOfDay(for: Date())
+        return calendar.date(byAdding: .second, value: -1, to: startOfToday) ?? Date()
+    }
+
+    var body: some View {
+        VStack(spacing: 0) {
+            Button(action: {
+                withAnimation {
+                    isExpanded.toggle()
+                    showStartDatePicker = false
+                    showEndDatePicker = false
+                }
+            }) {
+                HStack {
+                    Image(systemName: "calendar")
+                        .foregroundColor(.blue)
+
+                    VStack(alignment: .leading, spacing: 2) {
+                        Text("\(compactDateFormatter.string(from: startDate)) - \(compactDateFormatter.string(from: endDate))")
+                            .font(.subheadline)
+                            .foregroundColor(.primary)
+
+                        Text("(\(dayCount) days)")
+                            .font(.caption2)
+                            .foregroundColor(.secondary)
+                    }
+
+                    Spacer()
+
+                    if let availability = availability {
+                        HStack(spacing: 4) {
+                            Image(systemName: statusIcon(for: availability.dataQuality))
+                                .font(.caption)
+                                .foregroundColor(statusColor(for: availability.dataQuality))
+
+                            Text(String(format: "%.0f%%", availability.coveragePercentage))
+                                .font(.caption)
+                                .fontWeight(.medium)
+                                .foregroundColor(statusColor(for: availability.dataQuality))
+                        }
+                    }
+
+                    Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
+                        .font(.caption)
+                        .foregroundColor(.secondary)
+                }
+                .padding(.horizontal, 16)
+                .padding(.vertical, 12)
+            }
+            .background(Color.secondary.opacity(0.1))
+
+            if isExpanded {
+                VStack(spacing: 12) {
+                    HStack(spacing: 16) {
+                        VStack(alignment: .leading, spacing: 4) {
+                            Text("Start Date")
+                                .font(.caption)
+                                .foregroundColor(.secondary)
+
+                            Button(action: {
+                                showStartDatePicker.toggle()
+                                showEndDatePicker = false
+                            }) {
+                                HStack {
+                                    Image(systemName: "calendar")
+                                        .font(.caption)
+                                        .foregroundColor(.blue)
+                                    Text(dateFormatter.string(from: startDate))
+                                        .font(.subheadline)
+                                        .foregroundColor(.primary)
+                                }
+                                .padding(.horizontal, 10)
+                                .padding(.vertical, 6)
+                                .background(Color.secondary.opacity(0.15))
+                                .cornerRadius(6)
+                            }
+                        }
+
+                        Spacer()
+
+                        VStack(alignment: .leading, spacing: 4) {
+                            Text("End Date")
+                                .font(.caption)
+                                .foregroundColor(.secondary)
+
+                            Button(action: {
+                                showEndDatePicker.toggle()
+                                showStartDatePicker = false
+                            }) {
+                                HStack {
+                                    Image(systemName: "calendar")
+                                        .font(.caption)
+                                        .foregroundColor(.blue)
+                                    Text(dateFormatter.string(from: endDate))
+                                        .font(.subheadline)
+                                        .foregroundColor(.primary)
+                                }
+                                .padding(.horizontal, 10)
+                                .padding(.vertical, 6)
+                                .background(Color.secondary.opacity(0.15))
+                                .cornerRadius(6)
+                            }
+                        }
+                    }
+                    .padding(.top, 8)
+
+                    if showStartDatePicker {
+                        DatePicker(
+                            "Select Start Date",
+                            selection: $startDate,
+                            in: ...endDate,
+                            displayedComponents: [.date]
+                        )
+                        .datePickerStyle(.graphical)
+                        .onChange(of: startDate) { _ in
+                            startDate = dateTimeUtils.displayCalendar().startOfDay(for: startDate)
+                            showStartDatePicker = false
+                            onDateChange()
+                        }
+                    }
+
+                    if showEndDatePicker {
+                        DatePicker(
+                            "Select End Date",
+                            selection: $endDate,
+                            in: startDate ... lastFullDay,
+                            displayedComponents: [.date]
+                        )
+                        .datePickerStyle(.graphical)
+                        .onChange(of: endDate) { _ in
+                            let calendar = dateTimeUtils.displayCalendar()
+                            let startOfNextDay = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: endDate)) ?? endDate
+                            endDate = calendar.date(byAdding: .second, value: -1, to: startOfNextDay) ?? endDate
+                            showEndDatePicker = false
+                            onDateChange()
+                        }
+                    }
+
+                    Divider()
+
+                    HStack(spacing: 8) {
+                        QuickSelectButton(title: "7d") {
+                            setDateRange(days: 7)
+                        }
+                        QuickSelectButton(title: "14d") {
+                            setDateRange(days: 14)
+                        }
+                        QuickSelectButton(title: "30d") {
+                            setDateRange(days: 30)
+                        }
+                        QuickSelectButton(title: "90d") {
+                            setDateRange(days: 90)
+                        }
+                    }
+
+                    if let availability = availability {
+                        VStack(spacing: 6) {
+                            HStack {
+                                Text("Data Availability")
+                                    .font(.subheadline)
+                                    .fontWeight(.medium)
+
+                                Spacer()
+
+                                Text(String(format: "%.1f%%", availability.coveragePercentage))
+                                    .font(.subheadline)
+                                    .fontWeight(.semibold)
+                                    .foregroundColor(statusColor(for: availability.dataQuality))
+                            }
+
+                            HStack {
+                                Text("\(availability.actualReadings) of \(availability.totalExpectedReadings) expected readings")
+                                    .font(.caption)
+                                    .foregroundColor(.secondary)
+
+                                Spacer()
+
+                                if availability.missingIntervals > 0 {
+                                    Text("\(availability.missingIntervals) gaps")
+                                        .font(.caption)
+                                        .foregroundColor(.orange)
+                                }
+                            }
+
+                            Text("Expected: 1 CGM reading every 5 minutes")
+                                .font(.caption2)
+                                .foregroundColor(.secondary)
+                                .frame(maxWidth: .infinity, alignment: .leading)
+                        }
+                        .padding(12)
+                    }
+                }
+                .padding(.horizontal, 16)
+                .padding(.bottom, 12)
+                .background(Color.secondary.opacity(0.05))
+            }
+        }
+        .background(Color.secondary.opacity(0.05))
+        .cornerRadius(12)
+    }
+
+    private func setDateRange(days: Int) {
+        let calendar = dateTimeUtils.displayCalendar()
+        endDate = lastFullDay
+        let endDayStart = calendar.startOfDay(for: endDate)
+        let startDayStart = calendar.date(byAdding: .day, value: -days, to: endDayStart) ?? endDayStart
+        startDate = calendar.startOfDay(for: startDayStart)
+        showStartDatePicker = false
+        showEndDatePicker = false
+        onDateChange()
+    }
+
+    private func statusIcon(for quality: DataAvailabilityInfo.DataQuality) -> String {
+        switch quality {
+        case .excellent:
+            return "checkmark.circle.fill"
+        case .good:
+            return "checkmark.circle"
+        case .fair:
+            return "exclamationmark.triangle"
+        case .poor:
+            return "xmark.circle"
+        }
+    }
+
+    private func statusColor(for quality: DataAvailabilityInfo.DataQuality) -> Color {
+        switch quality {
+        case .excellent:
+            return .green
+        case .good:
+            return .blue
+        case .fair:
+            return .orange
+        case .poor:
+            return .red
+        }
+    }
+}
+
+struct QuickSelectButton: View {
+    let title: String
+    let action: () -> Void
+
+    var body: some View {
+        Button(action: action) {
+            Text(title)
+                .font(.caption)
+                .fontWeight(.medium)
+                .padding(.horizontal, 12)
+                .padding(.vertical, 6)
+                .background(Color.blue.opacity(0.1))
+                .foregroundColor(.blue)
+                .cornerRadius(6)
+        }
+    }
+}

+ 84 - 0
LoopFollow/Stats/GRI/GRICalculator.swift

@@ -0,0 +1,84 @@
+// LoopFollow
+// GRICalculator.swift
+
+import Foundation
+
+struct GRICalculationResult {
+    let gri: Double
+    let hypoComponent: Double
+    let hyperComponent: Double
+}
+
+class GRICalculator {
+    /// Calculate GRI (Glucose Risk Index) from BG data
+    /// GRI = (3.0 × HypoComponent) + (1.6 × HyperComponent)
+    static func calculate(bgData: [ShareGlucoseData]) -> GRICalculationResult {
+        guard !bgData.isEmpty else { return GRICalculationResult(gri: 0.0, hypoComponent: 0.0, hyperComponent: 0.0) }
+
+        let vLowThreshold = 54.0
+        let lowThreshold = 70.0
+        let highThreshold = 180.0
+        let vHighThreshold = 250.0
+
+        var vLowCount = 0
+        var lowCount = 0
+        var highCount = 0
+        var vHighCount = 0
+
+        for reading in bgData {
+            let glucose = Double(reading.sgv)
+
+            if glucose < vLowThreshold {
+                vLowCount += 1
+            } else if glucose < lowThreshold {
+                lowCount += 1
+            } else if glucose > vHighThreshold {
+                vHighCount += 1
+            } else if glucose > highThreshold {
+                highCount += 1
+            }
+        }
+
+        let totalCount = Double(bgData.count)
+        guard totalCount > 0 else { return GRICalculationResult(gri: 0.0, hypoComponent: 0.0, hyperComponent: 0.0) }
+
+        let vLowPercent = (Double(vLowCount) / totalCount) * 100.0
+        let lowPercent = (Double(lowCount) / totalCount) * 100.0
+        let highPercent = (Double(highCount) / totalCount) * 100.0
+        let vHighPercent = (Double(vHighCount) / totalCount) * 100.0
+
+        let hypoComponent = vLowPercent + (0.8 * lowPercent)
+        let hyperComponent = vHighPercent + (0.5 * highPercent)
+
+        let gri = (3.0 * hypoComponent) + (1.6 * hyperComponent)
+        return GRICalculationResult(
+            gri: min(gri, 100.0),
+            hypoComponent: hypoComponent,
+            hyperComponent: hyperComponent
+        )
+    }
+
+    static func calculateTimeSeries(bgData: [ShareGlucoseData]) -> [(date: Date, value: Double)] {
+        guard !bgData.isEmpty else { return [] }
+
+        var dailyBGData: [Date: [ShareGlucoseData]] = [:]
+        let calendar = dateTimeUtils.displayCalendar()
+
+        for reading in bgData {
+            let date = Date(timeIntervalSince1970: reading.date)
+            let dayStart = calendar.startOfDay(for: date)
+            if dailyBGData[dayStart] == nil {
+                dailyBGData[dayStart] = []
+            }
+            dailyBGData[dayStart]?.append(reading)
+        }
+
+        var griPoints: [(date: Date, value: Double)] = []
+        for (date, dayData) in dailyBGData.sorted(by: { $0.key < $1.key }) {
+            let dayGRIResult = calculate(bgData: dayData)
+            griPoints.append((date: date, value: dayGRIResult.gri))
+        }
+
+        return griPoints
+    }
+}

+ 138 - 0
LoopFollow/Stats/GRI/GRIRiskGridView.swift

@@ -0,0 +1,138 @@
+// LoopFollow
+// GRIRiskGridView.swift
+
+import Charts
+import SwiftUI
+
+struct GRIRiskGridView: UIViewRepresentable {
+    let hypoComponent: Double
+    let hyperComponent: Double
+    let gri: Double
+
+    func makeCoordinator() -> Coordinator {
+        Coordinator()
+    }
+
+    func makeUIView(context _: Context) -> UIView {
+        let containerView = NonInteractiveContainerView()
+        containerView.backgroundColor = .systemBackground
+
+        let chartView = ScatterChartView()
+        chartView.backgroundColor = .systemBackground
+        chartView.rightAxis.enabled = false
+        chartView.leftAxis.enabled = true
+        chartView.legend.enabled = false
+        chartView.chartDescription.enabled = false
+        chartView.leftAxis.axisMinimum = 0
+        chartView.leftAxis.axisMaximum = 60
+        chartView.leftAxis.forceLabelsEnabled = true
+        chartView.leftAxis.labelPosition = .outsideChart
+        chartView.rightAxis.enabled = false
+        chartView.xAxis.labelPosition = .bottom
+        chartView.xAxis.axisMinimum = 0
+        chartView.xAxis.axisMaximum = 30
+        chartView.isUserInteractionEnabled = false
+
+        containerView.addSubview(chartView)
+        chartView.translatesAutoresizingMaskIntoConstraints = false
+        NSLayoutConstraint.activate([
+            chartView.topAnchor.constraint(equalTo: containerView.topAnchor),
+            chartView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
+            chartView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
+            chartView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
+        ])
+
+        return containerView
+    }
+
+    class Coordinator {}
+
+    func updateUIView(_ containerView: UIView, context _: Context) {
+        guard let chartView = containerView.subviews.first as? ScatterChartView else { return }
+
+        chartView.data = nil
+
+        var zoneAEntries: [ChartDataEntry] = []
+        var zoneBEntries: [ChartDataEntry] = []
+        var zoneCEntries: [ChartDataEntry] = []
+        var zoneDEntries: [ChartDataEntry] = []
+        var zoneEEntries: [ChartDataEntry] = []
+
+        let step = 0.5
+        for hypo in stride(from: 0.0, through: 30.0, by: step) {
+            for hyper in stride(from: 0.0, through: 60.0, by: step) {
+                let griValue = (3.0 * hypo) + (1.6 * hyper)
+
+                guard griValue <= 100 else { continue }
+
+                let entry = ChartDataEntry(x: hypo, y: hyper)
+
+                if griValue <= 20 {
+                    zoneAEntries.append(entry)
+                } else if griValue <= 40 {
+                    zoneBEntries.append(entry)
+                } else if griValue <= 60 {
+                    zoneCEntries.append(entry)
+                } else if griValue <= 80 {
+                    zoneDEntries.append(entry)
+                } else {
+                    zoneEEntries.append(entry)
+                }
+            }
+        }
+
+        let zoneADataSet = ScatterChartDataSet(entries: zoneAEntries, label: "Zone A")
+        zoneADataSet.setColor(NSUIColor.systemGreen.withAlphaComponent(0.3))
+        zoneADataSet.scatterShapeSize = 4
+        zoneADataSet.drawValuesEnabled = false
+
+        let zoneBDataSet = ScatterChartDataSet(entries: zoneBEntries, label: "Zone B")
+        zoneBDataSet.setColor(NSUIColor.systemYellow.withAlphaComponent(0.3))
+        zoneBDataSet.scatterShapeSize = 4
+        zoneBDataSet.drawValuesEnabled = false
+
+        let zoneCDataSet = ScatterChartDataSet(entries: zoneCEntries, label: "Zone C")
+        zoneCDataSet.setColor(NSUIColor.systemOrange.withAlphaComponent(0.3))
+        zoneCDataSet.scatterShapeSize = 4
+        zoneCDataSet.drawValuesEnabled = false
+
+        let zoneDDataSet = ScatterChartDataSet(entries: zoneDEntries, label: "Zone D")
+        zoneDDataSet.setColor(NSUIColor.systemRed.withAlphaComponent(0.3))
+        zoneDDataSet.scatterShapeSize = 4
+        zoneDDataSet.drawValuesEnabled = false
+
+        let zoneEDataSet = ScatterChartDataSet(entries: zoneEEntries, label: "Zone E")
+        zoneEDataSet.setColor(NSUIColor.systemRed.withAlphaComponent(0.5))
+        zoneEDataSet.scatterShapeSize = 4
+        zoneEDataSet.drawValuesEnabled = false
+
+        let currentPoint = ChartDataEntry(x: hypoComponent, y: hyperComponent)
+        let currentDataSet = ScatterChartDataSet(entries: [currentPoint], label: "Current GRI")
+        currentDataSet.setColor(NSUIColor.label)
+        currentDataSet.scatterShapeSize = 12
+        currentDataSet.setScatterShape(.circle)
+        currentDataSet.drawValuesEnabled = false
+
+        let data = ScatterChartData()
+        data.append(zoneADataSet)
+        data.append(zoneBDataSet)
+        data.append(zoneCDataSet)
+        data.append(zoneDDataSet)
+        data.append(zoneEDataSet)
+        data.append(currentDataSet)
+
+        chartView.data = data
+
+        chartView.xAxis.valueFormatter = DefaultAxisValueFormatter { value, _ in
+            String(format: "%.0f", value)
+        }
+        chartView.leftAxis.valueFormatter = DefaultAxisValueFormatter { value, _ in
+            String(format: "%.0f", value)
+        }
+
+        chartView.xAxis.labelTextColor = .label
+        chartView.leftAxis.labelTextColor = .label
+
+        chartView.notifyDataSetChanged()
+    }
+}

+ 119 - 0
LoopFollow/Stats/GRI/GRIView.swift

@@ -0,0 +1,119 @@
+// LoopFollow
+// GRIView.swift
+
+import SwiftUI
+
+struct GRIView: View {
+    @ObservedObject var viewModel: GRIViewModel
+
+    private let legendColumns = [GridItem(.adaptive(minimum: 90), spacing: 12, alignment: .leading)]
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            HStack {
+                Text("GRI (Glucose Risk Index)")
+                    .font(.caption)
+                    .foregroundColor(.secondary)
+                Spacer()
+                if let gri = viewModel.gri {
+                    VStack(alignment: .trailing, spacing: 2) {
+                        Text(String(format: "%.0f", gri))
+                            .font(.title2)
+                            .fontWeight(.semibold)
+                            .foregroundColor(griColor(gri))
+                        Text(griZone(gri))
+                            .font(.caption2)
+                            .foregroundColor(.secondary)
+                    }
+                } else {
+                    Text("---")
+                        .font(.title2)
+                        .fontWeight(.semibold)
+                        .foregroundColor(.purple)
+                }
+            }
+
+            if let hypo = viewModel.griHypoComponent, let hyper = viewModel.griHyperComponent {
+                GRIRiskGridView(
+                    hypoComponent: hypo,
+                    hyperComponent: hyper,
+                    gri: viewModel.gri ?? 0
+                )
+                .frame(height: 250)
+                .allowsHitTesting(false)
+                .clipped()
+                HStack {
+                    Text("Hypoglycemia Component (%)")
+                        .font(.caption2)
+                        .foregroundColor(.secondary)
+                    Spacer()
+                    Text("Hyperglycemia Component (%)")
+                        .font(.caption2)
+                        .foregroundColor(.secondary)
+                }
+
+                LazyVGrid(columns: legendColumns, alignment: .leading, spacing: 8) {
+                    ZoneLegendItem(color: .green, label: "A (0-20)")
+                    ZoneLegendItem(color: .yellow, label: "B (21-40)")
+                    ZoneLegendItem(color: .orange, label: "C (41-60)")
+                    ZoneLegendItem(color: .red, label: "D (61-80)")
+                    ZoneLegendItem(color: .red.opacity(0.8), label: "E (81-100)")
+                }
+                .font(.caption2)
+            } else {
+                Text("No data available")
+                    .font(.caption)
+                    .foregroundColor(.secondary)
+                    .frame(height: 250)
+            }
+        }
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .padding()
+        .background(Color(.systemGray6))
+        .cornerRadius(12)
+    }
+
+    private func griColor(_ gri: Double) -> Color {
+        if gri <= 20 {
+            return .green
+        } else if gri <= 40 {
+            return .yellow
+        } else if gri <= 60 {
+            return .orange
+        } else if gri <= 80 {
+            return .red
+        } else {
+            return .red.opacity(0.8)
+        }
+    }
+
+    private func griZone(_ gri: Double) -> String {
+        if gri <= 20 {
+            return "Zone A"
+        } else if gri <= 40 {
+            return "Zone B"
+        } else if gri <= 60 {
+            return "Zone C"
+        } else if gri <= 80 {
+            return "Zone D"
+        } else {
+            return "Zone E"
+        }
+    }
+}
+
+struct ZoneLegendItem: View {
+    let color: Color
+    let label: String
+
+    var body: some View {
+        HStack(spacing: 4) {
+            Rectangle()
+                .fill(color.opacity(0.3))
+                .frame(width: 12, height: 12)
+            Text(label)
+                .foregroundColor(.secondary)
+                .lineLimit(1)
+        }
+    }
+}

+ 38 - 0
LoopFollow/Stats/GRI/GRIViewModel.swift

@@ -0,0 +1,38 @@
+// LoopFollow
+// GRIViewModel.swift
+
+import Combine
+import Foundation
+
+class GRIViewModel: ObservableObject {
+    @Published var gri: Double?
+    @Published var griHypoComponent: Double?
+    @Published var griHyperComponent: Double?
+    @Published var griDataPoints: [(date: Date, value: Double)] = []
+
+    private let dataService: StatsDataService
+
+    init(dataService: StatsDataService) {
+        self.dataService = dataService
+        calculateGRI()
+    }
+
+    func calculateGRI() {
+        let bgData = dataService.getBGData()
+        guard !bgData.isEmpty else { return }
+
+        let result = GRICalculator.calculate(bgData: bgData)
+        gri = result.gri
+        griHypoComponent = result.hypoComponent
+        griHyperComponent = result.hyperComponent
+
+        griDataPoints = GRICalculator.calculateTimeSeries(bgData: bgData)
+    }
+
+    func clearStats() {
+        gri = nil
+        griHypoComponent = nil
+        griHyperComponent = nil
+        griDataPoints = []
+    }
+}

+ 336 - 0
LoopFollow/Stats/SimpleStatsViewModel.swift

@@ -0,0 +1,336 @@
+// LoopFollow
+// SimpleStatsViewModel.swift
+
+import Combine
+import Foundation
+
+class SimpleStatsViewModel: ObservableObject {
+    @Published var gmi: Double?
+    @Published var avgGlucose: Double?
+    @Published var stdDeviation: Double?
+    @Published var coefficientOfVariation: Double?
+    @Published var totalDailyDose: Double?
+    @Published var programmedBasal: Double?
+    @Published var actualBasal: Double?
+    @Published var avgBolus: Double?
+    @Published var avgCarbs: Double?
+    @Published var totalNegativeBasal: Double?
+    @Published var totalPositiveBasal: Double?
+
+    private let dataService: StatsDataService
+
+    init(dataService: StatsDataService) {
+        self.dataService = dataService
+    }
+
+    func clearStats() {
+        gmi = nil
+        avgGlucose = nil
+        stdDeviation = nil
+        coefficientOfVariation = nil
+        totalDailyDose = nil
+        programmedBasal = nil
+        actualBasal = nil
+        avgBolus = nil
+        avgCarbs = nil
+        totalNegativeBasal = nil
+        totalPositiveBasal = nil
+    }
+
+    func calculateStats() {
+        let bgData = dataService.getBGData()
+        guard !bgData.isEmpty else { return }
+
+        let totalGlucose = bgData.reduce(0) { $0 + $1.sgv }
+        let avgBGmgdL = Double(totalGlucose) / Double(bgData.count)
+        avgGlucose = Storage.shared.units.value == "mg/dL" ? avgBGmgdL : avgBGmgdL * GlucoseConversion.mgDlToMmolL
+
+        let variance = bgData.reduce(0.0) { sum, reading in
+            let diff = Double(reading.sgv) - avgBGmgdL
+            return sum + (diff * diff)
+        }
+        let stdDevMgdL = sqrt(variance / Double(bgData.count))
+        stdDeviation = Storage.shared.units.value == "mg/dL" ? stdDevMgdL : stdDevMgdL * GlucoseConversion.mgDlToMmolL
+
+        gmi = 3.31 + (0.02392 * avgBGmgdL)
+
+        if avgBGmgdL > 0 {
+            coefficientOfVariation = (stdDevMgdL / avgBGmgdL) * 100.0
+        } else {
+            coefficientOfVariation = nil
+        }
+
+        let bolusesInPeriod = dataService.getBolusData()
+        let smbInPeriod = dataService.getSMBData()
+        let bolusTotal = bolusesInPeriod.reduce(0.0) { $0 + $1.value }
+        let smbTotal = smbInPeriod.reduce(0.0) { $0 + $1.value }
+        let totalBolusInPeriod = bolusTotal + smbTotal
+
+        let cutoffTime = Date().timeIntervalSince1970 - (Double(dataService.daysToAnalyze) * 24 * 60 * 60)
+        let allBolusDates = (bolusesInPeriod + smbInPeriod).map { $0.date }.filter { $0 >= cutoffTime }
+        let actualDays = calculateActualDaysCovered(dates: allBolusDates, requestedDays: dataService.daysToAnalyze)
+
+        if actualDays > 0 {
+            avgBolus = totalBolusInPeriod / Double(actualDays)
+        } else {
+            avgBolus = nil
+        }
+
+        let carbsInPeriod = dataService.getCarbData()
+
+        let calendar = dateTimeUtils.displayCalendar()
+        var dailyCarbs: [Date: Double] = [:]
+
+        for carb in carbsInPeriod {
+            let carbDate = Date(timeIntervalSince1970: carb.date)
+            let dayStart = calendar.startOfDay(for: carbDate)
+
+            if dailyCarbs[dayStart] == nil {
+                dailyCarbs[dayStart] = 0.0
+            }
+            dailyCarbs[dayStart]? += carb.value
+        }
+
+        let totalCarbsInPeriod = dailyCarbs.values.reduce(0.0, +)
+
+        let daysWithData = max(dailyCarbs.count, 1)
+
+        if daysWithData > 0 {
+            avgCarbs = totalCarbsInPeriod / Double(daysWithData)
+        } else {
+            avgCarbs = nil
+        }
+
+        let basalProfile = dataService.getBasalProfile()
+        let dailyProgrammedBasal = calculateProgrammedBasalFromProfile(basalProfile: basalProfile)
+        programmedBasal = dailyProgrammedBasal
+
+        // Calculate actual basal using temp basal adjustments
+        let tempBasalData = dataService.getTempBasalData()
+        let (totalActualBasal, actualBasalDays, totalPositiveAdjustment, totalNegativeAdjustment) = calculateActualBasalFromTempBasals(
+            tempBasals: tempBasalData,
+            basalProfile: basalProfile,
+            dailyProgrammedBasal: dailyProgrammedBasal
+        )
+
+        if actualBasalDays > 0 {
+            totalPositiveBasal = totalPositiveAdjustment / Double(actualBasalDays)
+            totalNegativeBasal = totalNegativeAdjustment / Double(actualBasalDays)
+        } else {
+            totalPositiveBasal = nil
+            totalNegativeBasal = nil
+        }
+
+        var avgDailyBolus = 0.0
+        var avgDailyBasal = 0.0
+
+        if actualDays > 0 {
+            avgDailyBolus = totalBolusInPeriod / Double(actualDays)
+        }
+
+        if actualBasalDays > 0 {
+            avgDailyBasal = totalActualBasal / Double(actualBasalDays)
+            actualBasal = avgDailyBasal
+        } else {
+            actualBasal = nil
+        }
+
+        if actualDays > 0 || actualBasalDays > 0 {
+            totalDailyDose = avgDailyBolus + avgDailyBasal
+        } else {
+            totalDailyDose = nil
+        }
+    }
+
+    /// Calculates actual basal delivered using:
+    /// Actual = Programmed Basal + Sum of all temp basal adjustments
+    /// Where adjustment = (temp_rate - scheduled_rate) * duration
+    private func calculateActualBasalFromTempBasals(
+        tempBasals: [StatsDataService.TempBasalEntry],
+        basalProfile: [MainViewController.basalProfileStruct],
+        dailyProgrammedBasal: Double
+    ) -> (totalBasal: Double, daysWithData: Int, totalPositiveAdjustment: Double, totalNegativeAdjustment: Double) {
+        let cutoffTime = dataService.startDate.timeIntervalSince1970
+        let endTime = dataService.endDate.timeIntervalSince1970
+
+        // Filter temp basals to the analysis period
+        let relevantTempBasals = tempBasals.filter { tempBasal in
+            let tempEnd = tempBasal.startTime + (tempBasal.durationMinutes * 60)
+            return tempEnd > cutoffTime && tempBasal.startTime < endTime
+        }
+
+        guard !relevantTempBasals.isEmpty else {
+            // No temp basals - return programmed basal for the period
+            return (dailyProgrammedBasal, dataService.daysToAnalyze, 0.0, 0.0)
+        }
+
+        // Calculate total adjustment from all temp basals
+        var totalAdjustment = 0.0
+        var totalPositiveAdjustment = 0.0
+        var totalNegativeAdjustment = 0.0
+
+        for tempBasal in relevantTempBasals {
+            // Clamp temp basal to analysis window
+            let effectiveStart = max(tempBasal.startTime, cutoffTime)
+            let effectiveEnd = min(tempBasal.startTime + (tempBasal.durationMinutes * 60), endTime)
+
+            guard effectiveEnd > effectiveStart else { continue }
+
+            // For each segment of this temp basal, calculate the adjustment
+            // We need to handle cases where the temp basal spans multiple profile segments
+            let adjustment = calculateAdjustmentForTempBasal(
+                tempRate: tempBasal.rate,
+                startTime: effectiveStart,
+                endTime: effectiveEnd,
+                profile: basalProfile
+            )
+            totalAdjustment += adjustment
+
+            if adjustment > 0 {
+                totalPositiveAdjustment += adjustment
+            } else if adjustment < 0 {
+                totalNegativeAdjustment += adjustment
+            }
+        }
+
+        // Calculate days with data
+        let calendar = dateTimeUtils.displayCalendar()
+        var uniqueDays = Set<Date>()
+        for tempBasal in relevantTempBasals {
+            let dateObj = Date(timeIntervalSince1970: tempBasal.startTime)
+            let dayStart = calendar.startOfDay(for: dateObj)
+            uniqueDays.insert(dayStart)
+        }
+        let daysWithData = min(uniqueDays.count, dataService.daysToAnalyze)
+
+        // Actual = Programmed + Adjustments
+        let totalActualBasal = (dailyProgrammedBasal * Double(daysWithData)) + totalAdjustment
+
+        return (totalActualBasal, daysWithData, totalPositiveAdjustment, totalNegativeAdjustment)
+    }
+
+    /// Calculates the adjustment (positive or negative) for a single temp basal
+    /// Adjustment = (temp_rate - scheduled_rate) * duration_hours
+    private func calculateAdjustmentForTempBasal(
+        tempRate: Double,
+        startTime: TimeInterval,
+        endTime: TimeInterval,
+        profile: [MainViewController.basalProfileStruct]
+    ) -> Double {
+        guard !profile.isEmpty, endTime > startTime else { return 0.0 }
+
+        var totalAdjustment = 0.0
+        let sortedProfile = profile.sorted { $0.timeAsSeconds < $1.timeAsSeconds }
+        let calendar = dateTimeUtils.displayCalendar()
+
+        var currentTime = startTime
+        while currentTime < endTime {
+            let currentDate = Date(timeIntervalSince1970: currentTime)
+            let dayStart = calendar.startOfDay(for: currentDate).timeIntervalSince1970
+            let nextDayStart = dayStart + 24 * 60 * 60
+
+            // Process each profile segment for this day
+            for i in 0 ..< sortedProfile.count {
+                let scheduledRate = sortedProfile[i].value
+                let segmentStartInDay = dayStart + sortedProfile[i].timeAsSeconds
+
+                let segmentEndInDay: TimeInterval
+                if i < sortedProfile.count - 1 {
+                    segmentEndInDay = dayStart + sortedProfile[i + 1].timeAsSeconds
+                } else {
+                    segmentEndInDay = nextDayStart
+                }
+
+                // Calculate overlap between this profile segment and the temp basal
+                let overlapStart = max(currentTime, segmentStartInDay)
+                let overlapEnd = min(endTime, segmentEndInDay)
+
+                if overlapEnd > overlapStart {
+                    let durationHours = (overlapEnd - overlapStart) / 3600.0
+                    // Adjustment = (temp_rate - scheduled_rate) * duration
+                    let adjustment = (tempRate - scheduledRate) * durationHours
+                    totalAdjustment += adjustment
+                }
+            }
+
+            // Move to next day
+            currentTime = nextDayStart
+        }
+
+        return totalAdjustment
+    }
+
+    private func getScheduledBasalRate(for time: TimeInterval, profile: [MainViewController.basalProfileStruct]) -> Double {
+        guard !profile.isEmpty else { return 0.0 }
+
+        let calendar = dateTimeUtils.displayCalendar()
+        let date = Date(timeIntervalSince1970: time)
+        let components = calendar.dateComponents([.hour, .minute, .second], from: date)
+
+        let hours = components.hour ?? 0
+        let minutes = components.minute ?? 0
+        let seconds = components.second ?? 0
+        let secondsSinceMidnight = Double(hours * 3600 + minutes * 60 + seconds)
+
+        let sortedProfile = profile.sorted { $0.timeAsSeconds < $1.timeAsSeconds }
+
+        for i in 0 ..< sortedProfile.count {
+            let current = sortedProfile[i]
+            let nextTime: Double
+            if i < sortedProfile.count - 1 {
+                nextTime = sortedProfile[i + 1].timeAsSeconds
+            } else {
+                nextTime = 24 * 60 * 60
+            }
+
+            if secondsSinceMidnight >= current.timeAsSeconds && secondsSinceMidnight < nextTime {
+                return current.value
+            }
+        }
+
+        return sortedProfile.first?.value ?? 0.0
+    }
+
+    private func calculateActualDaysCovered(dates: [TimeInterval], requestedDays: Int) -> Int {
+        guard !dates.isEmpty else { return requestedDays }
+
+        let calendar = dateTimeUtils.displayCalendar()
+        let cutoffTime = Date().timeIntervalSince1970 - (Double(requestedDays) * 24 * 60 * 60)
+        let filteredDates = dates.filter { $0 >= cutoffTime }
+
+        var uniqueDays = Set<Date>()
+        for date in filteredDates {
+            let dateObj = Date(timeIntervalSince1970: date)
+            let dayStart = calendar.startOfDay(for: dateObj)
+            uniqueDays.insert(dayStart)
+        }
+
+        return min(uniqueDays.count, requestedDays)
+    }
+
+    private func calculateProgrammedBasalFromProfile(basalProfile: [MainViewController.basalProfileStruct]) -> Double {
+        guard !basalProfile.isEmpty else { return 0.0 }
+
+        let sortedProfile = basalProfile.sorted { $0.timeAsSeconds < $1.timeAsSeconds }
+
+        var totalBasal = 0.0
+        let secondsInDay = 24 * 60 * 60
+
+        for i in 0 ..< sortedProfile.count {
+            let current = sortedProfile[i]
+            let currentTime = Double(current.timeAsSeconds)
+
+            let nextTime: Double
+            if i < sortedProfile.count - 1 {
+                nextTime = Double(sortedProfile[i + 1].timeAsSeconds)
+            } else {
+                nextTime = Double(secondsInDay)
+            }
+
+            let durationHours = (nextTime - currentTime) / 3600.0
+            totalBasal += current.value * durationHours
+        }
+
+        return totalBasal
+    }
+}

+ 559 - 0
LoopFollow/Stats/StatsDataFetcher.swift

@@ -0,0 +1,559 @@
+// LoopFollow
+// StatsDataFetcher.swift
+
+import Foundation
+
+class StatsDataFetcher {
+    weak var mainViewController: MainViewController?
+    weak var dataService: StatsDataService?
+
+    init(mainViewController: MainViewController?) {
+        self.mainViewController = mainViewController
+    }
+
+    func fetchBGData(days: Int, completion: @escaping () -> Void) {
+        guard let mainVC = mainViewController, IsNightscoutEnabled() else {
+            completion()
+            return
+        }
+
+        var parameters: [String: String] = [:]
+        let utcISODateFormatter = ISO8601DateFormatter()
+        let startDate = dataService?.startDate ?? dateTimeUtils.displayCalendar().date(byAdding: .day, value: -1 * days, to: Date())!
+        parameters["count"] = "\(days * 2 * 24 * 60 / 5)"
+        parameters["find[dateString][$gte]"] = utcISODateFormatter.string(from: startDate)
+        parameters["find[type][$ne]"] = "cal"
+
+        NightscoutUtils.executeRequest(eventType: .sgv, parameters: parameters) { (result: Result<[ShareGlucoseData], Error>) in
+            switch result {
+            case let .success(entriesResponse):
+                var nsData = entriesResponse
+                DispatchQueue.main.async {
+                    // Transform NS data
+                    for i in 0 ..< nsData.count {
+                        nsData[i].date /= 1000
+                        nsData[i].date.round(FloatingPointRoundingRule.toNearestOrEven)
+                    }
+
+                    var nsData2: [ShareGlucoseData] = []
+                    var lastAddedTime = Double.infinity
+                    var lastAddedSGV: Int?
+                    let minInterval: Double = 30
+
+                    for reading in nsData {
+                        if (lastAddedSGV == nil || lastAddedSGV != reading.sgv) || (lastAddedTime - reading.date >= minInterval) {
+                            nsData2.append(reading)
+                            lastAddedTime = reading.date
+                            lastAddedSGV = reading.sgv
+                        }
+                    }
+
+                    let cutoffTime = self.dataService?.startDate.timeIntervalSince1970 ?? (Date().timeIntervalSince1970 - (Double(days) * 24 * 60 * 60))
+                    mainVC.statsBGData.removeAll { $0.date < cutoffTime }
+
+                    let existingDates = Set(mainVC.statsBGData.map { Int($0.date) })
+                    for reading in nsData2 {
+                        if !existingDates.contains(Int(reading.date)), reading.date >= cutoffTime {
+                            mainVC.statsBGData.append(reading)
+                        }
+                    }
+
+                    mainVC.statsBGData.sort { $0.date < $1.date }
+
+                    completion()
+                }
+            case let .failure(error):
+                LogManager.shared.log(category: .nightscout, message: "Failed to fetch stats BG data: \(error)", limitIdentifier: "Failed to fetch stats BG data")
+                DispatchQueue.main.async {
+                    completion()
+                }
+            }
+        }
+    }
+
+    func fetchTreatmentsData(days: Int, completion: @escaping () -> Void) {
+        guard let mainVC = mainViewController, IsNightscoutEnabled(), Storage.shared.downloadTreatments.value else {
+            completion()
+            return
+        }
+
+        ensureBasalProfileLoaded(mainVC: mainVC) {
+            self.fetchAndMergeTreatments(days: days, mainVC: mainVC, completion: completion)
+        }
+    }
+
+    private func ensureBasalProfileLoaded(mainVC: MainViewController, completion: @escaping () -> Void) {
+        if !mainVC.basalProfile.isEmpty {
+            completion()
+            return
+        }
+
+        NightscoutUtils.executeRequest(eventType: .profile, parameters: [:]) { (result: Result<NSProfile, Error>) in
+            switch result {
+            case let .success(profileData):
+                let profileStore = profileData.store["default"] ??
+                    profileData.store["Default"] ??
+                    profileData.store[profileData.defaultProfile]
+
+                DispatchQueue.main.async {
+                    if let profileStore {
+                        mainVC.basalProfile = profileStore.basal.map {
+                            MainViewController.basalProfileStruct(
+                                value: $0.value,
+                                time: $0.time,
+                                timeAsSeconds: $0.timeAsSeconds
+                            )
+                        }
+                    }
+                    completion()
+                }
+            case let .failure(error):
+                LogManager.shared.log(
+                    category: .nightscout,
+                    message: "Failed to fetch profile data for stats basal calculations: \(error.localizedDescription)"
+                )
+                DispatchQueue.main.async {
+                    completion()
+                }
+            }
+        }
+    }
+
+    private func fetchAndMergeTreatments(days: Int, mainVC: MainViewController, completion: @escaping () -> Void) {
+        let utcISODateFormatter = ISO8601DateFormatter()
+        utcISODateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+        utcISODateFormatter.timeZone = TimeZone(abbreviation: "UTC")
+
+        let startDate = dataService?.startDate ?? dateTimeUtils.displayCalendar().date(byAdding: .day, value: -1 * days, to: Date())!
+        let endDate = dataService?.endDate ?? Date()
+
+        let startTimeString = utcISODateFormatter.string(from: startDate)
+        let currentTimeString = utcISODateFormatter.string(from: endDate)
+
+        let estimatedCount = max(days * 100, 5000)
+        let parameters: [String: String] = [
+            "find[created_at][$gte]": startTimeString,
+            "find[created_at][$lte]": currentTimeString,
+            "count": "\(estimatedCount)",
+        ]
+
+        NightscoutUtils.executeDynamicRequest(eventType: .treatments, parameters: parameters) { (result: Result<Any, Error>) in
+            switch result {
+            case let .success(data):
+                if let entries = data as? [[String: AnyObject]] {
+                    DispatchQueue.main.async {
+                        self.fetchAndMergeBolusData(entries: entries, days: days, mainVC: mainVC)
+                        self.fetchAndMergeSMBData(entries: entries, days: days, mainVC: mainVC)
+                        self.fetchAndMergeCarbData(entries: entries, days: days, mainVC: mainVC)
+                        self.fetchAndMergeBasalData(entries: entries, days: days, mainVC: mainVC)
+                        completion()
+                    }
+                } else {
+                    DispatchQueue.main.async {
+                        completion()
+                    }
+                }
+            case let .failure(error):
+                LogManager.shared.log(category: .nightscout, message: "Failed to fetch stats treatments data: \(error.localizedDescription)")
+                DispatchQueue.main.async {
+                    completion()
+                }
+            }
+        }
+    }
+
+    private func fetchAndMergeBolusData(entries: [[String: AnyObject]], days: Int, mainVC: MainViewController) {
+        let cutoffTime = dataService?.startDate.timeIntervalSince1970 ?? (Date().timeIntervalSince1970 - (Double(days) * 24 * 60 * 60))
+
+        var bolusEntries: [[String: AnyObject]] = []
+        for entry in entries {
+            guard let eventType = entry["eventType"] as? String else { continue }
+            if eventType == "Correction Bolus" || eventType == "Bolus" || eventType == "External Insulin" {
+                if let automatic = entry["automatic"] as? Bool, automatic {
+                    continue
+                }
+                bolusEntries.append(entry)
+            } else if eventType == "Meal Bolus" {
+                bolusEntries.append(entry)
+            }
+        }
+
+        mainVC.statsBolusData.removeAll { $0.date < cutoffTime }
+
+        let existingDates = Set(mainVC.statsBolusData.map { Int($0.date) })
+        var lastFoundIndex = 0
+
+        for currentEntry in bolusEntries.reversed() {
+            var bolusDate: String
+            if currentEntry["timestamp"] != nil {
+                bolusDate = currentEntry["timestamp"] as! String
+            } else if currentEntry["created_at"] != nil {
+                bolusDate = currentEntry["created_at"] as! String
+            } else {
+                continue
+            }
+
+            guard let parsedDate = NightscoutUtils.parseDate(bolusDate),
+                  let bolus = currentEntry["insulin"] as? Double else { continue }
+
+            let dateTimeStamp = parsedDate.timeIntervalSince1970
+            if dateTimeStamp < cutoffTime { continue }
+
+            // Avoid duplicates (use Int to handle floating point precision)
+            if existingDates.contains(Int(dateTimeStamp)) { continue }
+
+            let sgv = mainVC.findNearestBGbyTime(needle: dateTimeStamp, haystack: mainVC.statsBGData, startingIndex: lastFoundIndex)
+            lastFoundIndex = sgv.foundIndex
+
+            let dot = MainViewController.bolusGraphStruct(value: bolus, date: Double(dateTimeStamp), sgv: Int(sgv.sgv + 20))
+            mainVC.statsBolusData.append(dot)
+        }
+
+        mainVC.statsBolusData.sort { $0.date < $1.date }
+    }
+
+    private func fetchAndMergeSMBData(entries: [[String: AnyObject]], days: Int, mainVC: MainViewController) {
+        let cutoffTime = dataService?.startDate.timeIntervalSince1970 ?? (Date().timeIntervalSince1970 - (Double(days) * 24 * 60 * 60))
+
+        var smbEntries: [[String: AnyObject]] = []
+        for entry in entries {
+            guard let eventType = entry["eventType"] as? String else { continue }
+            if eventType == "SMB" {
+                smbEntries.append(entry)
+            } else if eventType == "Correction Bolus" || eventType == "Bolus" || eventType == "External Insulin" {
+                if let automatic = entry["automatic"] as? Bool, automatic {
+                    smbEntries.append(entry)
+                }
+            }
+        }
+
+        mainVC.statsSMBData.removeAll { $0.date < cutoffTime }
+
+        let existingDates = Set(mainVC.statsSMBData.map { Int($0.date) })
+        var lastFoundIndex = 0
+
+        for currentEntry in smbEntries.reversed() {
+            var bolusDate: String
+            if currentEntry["timestamp"] != nil {
+                bolusDate = currentEntry["timestamp"] as! String
+            } else if currentEntry["created_at"] != nil {
+                bolusDate = currentEntry["created_at"] as! String
+            } else {
+                continue
+            }
+
+            guard let parsedDate = NightscoutUtils.parseDate(bolusDate),
+                  let bolus = currentEntry["insulin"] as? Double else { continue }
+
+            let dateTimeStamp = parsedDate.timeIntervalSince1970
+            if dateTimeStamp < cutoffTime { continue }
+
+            if existingDates.contains(Int(dateTimeStamp)) { continue }
+
+            let sgv = mainVC.findNearestBGbyTime(needle: dateTimeStamp, haystack: mainVC.statsBGData, startingIndex: lastFoundIndex)
+            lastFoundIndex = sgv.foundIndex
+
+            let dot = MainViewController.bolusGraphStruct(value: bolus, date: Double(dateTimeStamp), sgv: Int(sgv.sgv + 20))
+            mainVC.statsSMBData.append(dot)
+        }
+
+        mainVC.statsSMBData.sort { $0.date < $1.date }
+    }
+
+    private func fetchAndMergeCarbData(entries: [[String: AnyObject]], days: Int, mainVC: MainViewController) {
+        let cutoffTime = dataService?.startDate.timeIntervalSince1970 ?? (Date().timeIntervalSince1970 - (Double(days) * 24 * 60 * 60))
+        let now = dataService?.endDate.timeIntervalSince1970 ?? Date().timeIntervalSince1970
+
+        var carbEntries: [[String: AnyObject]] = []
+        for entry in entries {
+            guard let eventType = entry["eventType"] as? String else { continue }
+            if eventType == "Carb Correction" || eventType == "Meal Bolus" {
+                carbEntries.append(entry)
+            }
+        }
+
+        mainVC.statsCarbData.removeAll { $0.date < cutoffTime || $0.date > now }
+
+        let existingDates = Set(mainVC.statsCarbData.map { Int($0.date) })
+        var lastFoundIndex = 0
+        var lastFoundBolus = 0
+
+        for currentEntry in carbEntries.reversed() {
+            var carbDate: String
+            if currentEntry["timestamp"] != nil {
+                carbDate = currentEntry["timestamp"] as! String
+            } else if currentEntry["created_at"] != nil {
+                carbDate = currentEntry["created_at"] as! String
+            } else {
+                continue
+            }
+
+            let absorptionTime = currentEntry["absorptionTime"] as? Int ?? 0
+
+            guard let parsedDate = NightscoutUtils.parseDate(carbDate),
+                  let carbs = currentEntry["carbs"] as? Double else { continue }
+
+            let dateTimeStamp = parsedDate.timeIntervalSince1970
+
+            if dateTimeStamp < cutoffTime || dateTimeStamp > now { continue }
+
+            if existingDates.contains(Int(dateTimeStamp)) { continue }
+
+            let sgv = mainVC.findNearestBGbyTime(needle: dateTimeStamp, haystack: mainVC.statsBGData, startingIndex: lastFoundIndex)
+            lastFoundIndex = sgv.foundIndex
+
+            var offset = -50
+            if sgv.sgv < Double(mainVC.calculateMaxBgGraphValue() - 100) {
+                let bolusTime = mainVC.findNearestBolusbyTime(timeWithin: 300, needle: dateTimeStamp, haystack: mainVC.statsBolusData, startingIndex: lastFoundBolus)
+                lastFoundBolus = bolusTime.foundIndex
+                offset = bolusTime.offset ? 70 : 20
+            }
+
+            let dot = MainViewController.carbGraphStruct(value: Double(carbs), date: Double(dateTimeStamp), sgv: Int(sgv.sgv + Double(offset)), absorptionTime: absorptionTime)
+            mainVC.statsCarbData.append(dot)
+        }
+
+        mainVC.statsCarbData.sort { $0.date < $1.date }
+    }
+
+    private func fetchAndMergeBasalData(entries: [[String: AnyObject]], days: Int, mainVC: MainViewController) {
+        let cutoffTime = dataService?.startDate.timeIntervalSince1970 ?? (Date().timeIntervalSince1970 - (Double(days) * 24 * 60 * 60))
+        let now = dataService?.endDate.timeIntervalSince1970 ?? Date().timeIntervalSince1970
+
+        var basalEntries: [[String: AnyObject]] = []
+        for entry in entries {
+            guard let eventType = entry["eventType"] as? String else { continue }
+            if eventType == "Temp Basal" {
+                basalEntries.append(entry)
+            }
+        }
+
+        mainVC.statsBasalData.removeAll { $0.date < cutoffTime }
+
+        // Clear and rebuild temp basal entries for stats calculation
+        dataService?.tempBasalEntries.removeAll { $0.startTime < cutoffTime }
+        var existingTempBasalTimes = Set(dataService?.tempBasalEntries.map { Int($0.startTime) } ?? [])
+
+        var existingDates = Set(mainVC.statsBasalData.map { Int($0.date) })
+        var tempArray = basalEntries
+        tempArray.reverse()
+
+        for i in 0 ..< tempArray.count {
+            guard let currentEntry = tempArray[i] as [String: AnyObject]? else { continue }
+
+            let dateString = currentEntry["timestamp"] as? String ?? currentEntry["created_at"] as? String
+            guard let rawDateStr = dateString,
+                  let dateParsed = NightscoutUtils.parseDate(rawDateStr)
+            else {
+                continue
+            }
+
+            let dateTimeStamp = dateParsed.timeIntervalSince1970
+            if dateTimeStamp < cutoffTime { continue }
+
+            guard let basalRate = currentEntry["absolute"] as? Double else {
+                continue
+            }
+
+            var duration = currentEntry["duration"] as? Double ?? 0.0
+
+            // Store raw temp basal entry for stats calculation
+            // Adjust duration if it overlaps with next temp basal
+            var effectiveDuration = duration
+            if i < tempArray.count - 1 {
+                let nextEntry = tempArray[i + 1] as [String: AnyObject]?
+                let nextDateStr = nextEntry?["timestamp"] as? String ?? nextEntry?["created_at"] as? String
+                if let rawNext = nextDateStr,
+                   let nextDateParsed = NightscoutUtils.parseDate(rawNext)
+                {
+                    let nextDateTimeStamp = nextDateParsed.timeIntervalSince1970
+                    let tempBasalEnd = dateTimeStamp + (duration * 60)
+                    if nextDateTimeStamp < tempBasalEnd {
+                        // Adjust duration to end when next temp basal starts
+                        effectiveDuration = (nextDateTimeStamp - dateTimeStamp) / 60.0
+                    }
+                }
+            }
+
+            if !existingTempBasalTimes.contains(Int(dateTimeStamp)) {
+                let tempBasalEntry = StatsDataService.TempBasalEntry(
+                    rate: basalRate,
+                    startTime: dateTimeStamp,
+                    durationMinutes: effectiveDuration > 0 ? effectiveDuration : 30.0
+                )
+                dataService?.tempBasalEntries.append(tempBasalEntry)
+                existingTempBasalTimes.insert(Int(dateTimeStamp))
+            }
+
+            if i > 0 {
+                let priorEntry = tempArray[i - 1] as [String: AnyObject]?
+                let priorDateStr = priorEntry?["timestamp"] as? String ?? priorEntry?["created_at"] as? String
+                if let rawPrior = priorDateStr,
+                   let priorDateParsed = NightscoutUtils.parseDate(rawPrior)
+                {
+                    let priorDateTimeStamp = priorDateParsed.timeIntervalSince1970
+                    let priorDuration = priorEntry?["duration"] as? Double ?? 0.0
+                    let priorEndTime = priorDateTimeStamp + (priorDuration * 60)
+
+                    if (dateTimeStamp - priorEndTime) > 15 {
+                        let gapEntries = createScheduledBasalEntriesForGap(
+                            from: priorEndTime,
+                            to: dateTimeStamp,
+                            profile: mainVC.basalProfile,
+                            existingDates: existingDates
+                        )
+                        for entry in gapEntries {
+                            if !existingDates.contains(Int(entry.date)) {
+                                mainVC.statsBasalData.append(entry)
+                                existingDates.insert(Int(entry.date))
+                            }
+                        }
+                    }
+                }
+            }
+
+            let startDot = MainViewController.basalGraphStruct(basalRate: basalRate, date: dateTimeStamp)
+            if !existingDates.contains(Int(startDot.date)) {
+                mainVC.statsBasalData.append(startDot)
+                existingDates.insert(Int(startDot.date))
+            }
+
+            var endTime = dateTimeStamp + (duration * 60)
+            if i == tempArray.count - 1, duration == 0.0 {
+                endTime = dateTimeStamp + (30 * 60)
+            }
+
+            if i < tempArray.count - 1 {
+                let nextEntry = tempArray[i + 1] as [String: AnyObject]?
+                let nextDateStr = nextEntry?["timestamp"] as? String ?? nextEntry?["created_at"] as? String
+                if let rawNext = nextDateStr,
+                   let nextDateParsed = NightscoutUtils.parseDate(rawNext)
+                {
+                    let nextDateTimeStamp = nextDateParsed.timeIntervalSince1970
+                    if nextDateTimeStamp < endTime {
+                        endTime = nextDateTimeStamp
+                    }
+                }
+            }
+
+            let endDot = MainViewController.basalGraphStruct(basalRate: basalRate, date: endTime)
+            if !existingDates.contains(Int(endDot.date)) {
+                mainVC.statsBasalData.append(endDot)
+                existingDates.insert(Int(endDot.date))
+            }
+        }
+
+        if !tempArray.isEmpty {
+            let firstEntry = tempArray.first as [String: AnyObject]?
+            let firstDateStr = firstEntry?["timestamp"] as? String ?? firstEntry?["created_at"] as? String
+            if let rawFirst = firstDateStr,
+               let firstDateParsed = NightscoutUtils.parseDate(rawFirst)
+            {
+                let firstTempBasalStart = firstDateParsed.timeIntervalSince1970
+                if firstTempBasalStart > cutoffTime {
+                    let gapEntries = createScheduledBasalEntriesForGap(
+                        from: cutoffTime,
+                        to: firstTempBasalStart,
+                        profile: mainVC.basalProfile,
+                        existingDates: existingDates
+                    )
+                    for entry in gapEntries {
+                        if !existingDates.contains(Int(entry.date)) {
+                            mainVC.statsBasalData.append(entry)
+                            existingDates.insert(Int(entry.date))
+                        }
+                    }
+                }
+            }
+        } else if !mainVC.basalProfile.isEmpty {
+            let gapEntries = createScheduledBasalEntriesForGap(
+                from: cutoffTime,
+                to: now,
+                profile: mainVC.basalProfile,
+                existingDates: existingDates
+            )
+            for entry in gapEntries {
+                if !existingDates.contains(Int(entry.date)) {
+                    mainVC.statsBasalData.append(entry)
+                    existingDates.insert(Int(entry.date))
+                }
+            }
+        }
+
+        if !tempArray.isEmpty {
+            let lastEntry = tempArray.last as [String: AnyObject]?
+            let lastDateStr = lastEntry?["timestamp"] as? String ?? lastEntry?["created_at"] as? String
+            let lastDuration = lastEntry?["duration"] as? Double ?? 30.0
+            if let rawLast = lastDateStr,
+               let lastDateParsed = NightscoutUtils.parseDate(rawLast)
+            {
+                let lastTempBasalEnd = lastDateParsed.timeIntervalSince1970 + (lastDuration * 60)
+                if lastTempBasalEnd < now {
+                    let gapEntries = createScheduledBasalEntriesForGap(
+                        from: lastTempBasalEnd,
+                        to: now,
+                        profile: mainVC.basalProfile,
+                        existingDates: existingDates
+                    )
+                    for entry in gapEntries {
+                        if !existingDates.contains(Int(entry.date)) {
+                            mainVC.statsBasalData.append(entry)
+                            existingDates.insert(Int(entry.date))
+                        }
+                    }
+                }
+            }
+        }
+
+        mainVC.statsBasalData.sort { $0.date < $1.date }
+    }
+
+    private func createScheduledBasalEntriesForGap(
+        from startTime: TimeInterval,
+        to endTime: TimeInterval,
+        profile: [MainViewController.basalProfileStruct],
+        existingDates: Set<Int>
+    ) -> [MainViewController.basalGraphStruct] {
+        guard !profile.isEmpty, endTime > startTime else { return [] }
+
+        var entries: [MainViewController.basalGraphStruct] = []
+        let sortedProfile = profile.sorted { $0.timeAsSeconds < $1.timeAsSeconds }
+        let calendar = dateTimeUtils.displayCalendar()
+
+        var currentTime = startTime
+        while currentTime < endTime {
+            let currentDate = Date(timeIntervalSince1970: currentTime)
+            let dayStart = calendar.startOfDay(for: currentDate).timeIntervalSince1970
+            let nextDayStart = dayStart + 24 * 60 * 60
+
+            for i in 0 ..< sortedProfile.count {
+                let segmentRate = sortedProfile[i].value
+                let segmentStartInDay = dayStart + sortedProfile[i].timeAsSeconds
+
+                let segmentEndInDay: TimeInterval
+                if i < sortedProfile.count - 1 {
+                    segmentEndInDay = dayStart + sortedProfile[i + 1].timeAsSeconds
+                } else {
+                    segmentEndInDay = nextDayStart
+                }
+
+                let overlapStart = max(currentTime, segmentStartInDay)
+                let overlapEnd = min(endTime, segmentEndInDay)
+
+                if overlapEnd > overlapStart, !existingDates.contains(Int(overlapStart)) {
+                    let startEntry = MainViewController.basalGraphStruct(basalRate: segmentRate, date: overlapStart)
+                    entries.append(startEntry)
+
+                    if overlapEnd < endTime, !existingDates.contains(Int(overlapEnd)) {
+                        let endEntry = MainViewController.basalGraphStruct(basalRate: segmentRate, date: overlapEnd)
+                        entries.append(endEntry)
+                    }
+                }
+            }
+
+            currentTime = nextDayStart
+        }
+
+        return entries
+    }
+}

+ 151 - 0
LoopFollow/Stats/StatsDataService.swift

@@ -0,0 +1,151 @@
+// LoopFollow
+// StatsDataService.swift
+
+import Foundation
+
+class StatsDataService {
+    weak var mainViewController: MainViewController?
+
+    var daysToAnalyze: Int = 14 // Keep for backward compatibility
+    var startDate: Date = dateTimeUtils.displayCalendar().date(byAdding: .day, value: -14, to: Date()) ?? Date()
+    var endDate: Date = .init()
+
+    private let dataFetcher: StatsDataFetcher
+
+    /// Stores raw temp basal entries with rate and duration
+    var tempBasalEntries: [TempBasalEntry] = []
+
+    /// Structure to hold temp basal data for calculations
+    struct TempBasalEntry {
+        let rate: Double // U/hr (absolute rate)
+        let startTime: TimeInterval // Unix timestamp
+        let durationMinutes: Double // Duration in minutes
+    }
+
+    init(mainViewController: MainViewController?) {
+        self.mainViewController = mainViewController
+        dataFetcher = StatsDataFetcher(mainViewController: mainViewController)
+        dataFetcher.dataService = self
+    }
+
+    /// Update the date range for analysis
+    func updateDateRange(start: Date, end: Date) {
+        startDate = start
+        endDate = end
+        // Also update daysToAnalyze for compatibility with existing code
+        let daysBetween = dateTimeUtils.displayCalendar().dateComponents([.day], from: start, to: end).day ?? 14
+        daysToAnalyze = max(daysBetween, 1)
+    }
+
+    func ensureDataAvailable(onProgress: @escaping () -> Void, completion: @escaping () -> Void) {
+        guard let mainVC = mainViewController else {
+            completion()
+            return
+        }
+
+        let cutoffTime = startDate.timeIntervalSince1970
+        let endTime = endDate.timeIntervalSince1970
+
+        let oldestBG = mainVC.statsBGData.filter { $0.date >= cutoffTime && $0.date <= endTime }.min(by: { $0.date < $1.date })?.date
+        let oldestBolus = mainVC.statsBolusData.filter { $0.date >= cutoffTime && $0.date <= endTime }.min(by: { $0.date < $1.date })?.date
+        let oldestCarb = mainVC.statsCarbData.filter { $0.date >= cutoffTime && $0.date <= endTime }.min(by: { $0.date < $1.date })?.date
+        let oldestBasal = mainVC.statsBasalData.filter { $0.date >= cutoffTime && $0.date <= endTime }.min(by: { $0.date < $1.date })?.date
+
+        let bgDataCount = mainVC.statsBGData.filter { $0.date >= cutoffTime && $0.date <= endTime }.count
+        let bolusDataCount = mainVC.statsBolusData.filter { $0.date >= cutoffTime && $0.date <= endTime }.count
+        let carbDataCount = mainVC.statsCarbData.filter { $0.date >= cutoffTime && $0.date <= endTime }.count
+        let basalDataCount = mainVC.statsBasalData.filter { $0.date >= cutoffTime && $0.date <= endTime }.count
+
+        let minExpectedBGEntries = max(daysToAnalyze * 6, 12)
+        let hasEnoughBGData = bgDataCount >= minExpectedBGEntries && (oldestBG ?? endTime) <= cutoffTime + (24 * 60 * 60)
+        let minExpectedTreatmentEntries = max(daysToAnalyze, 1)
+        let hasEnoughTreatmentData = (bolusDataCount + carbDataCount + basalDataCount) >= minExpectedTreatmentEntries &&
+            (oldestBolus ?? endTime) <= cutoffTime + (24 * 60 * 60) &&
+            (oldestCarb ?? endTime) <= cutoffTime + (24 * 60 * 60) &&
+            (oldestBasal ?? endTime) <= cutoffTime + (24 * 60 * 60)
+
+        if !hasEnoughBGData {
+            dataFetcher.fetchBGData(days: daysToAnalyze) {
+                DispatchQueue.main.async {
+                    onProgress()
+                }
+
+                if !hasEnoughTreatmentData {
+                    self.dataFetcher.fetchTreatmentsData(days: self.daysToAnalyze) {
+                        DispatchQueue.main.async {
+                            onProgress()
+                            completion()
+                        }
+                    }
+                } else {
+                    completion()
+                }
+            }
+        } else if !hasEnoughTreatmentData {
+            dataFetcher.fetchTreatmentsData(days: daysToAnalyze) {
+                DispatchQueue.main.async {
+                    onProgress()
+                    completion()
+                }
+            }
+        } else {
+            completion()
+        }
+    }
+
+    func getBGData() -> [ShareGlucoseData] {
+        guard let mainVC = mainViewController else { return [] }
+        let startTime = startDate.timeIntervalSince1970
+        let endTime = endDate.timeIntervalSince1970
+        return mainVC.statsBGData.filter { $0.date >= startTime && $0.date <= endTime }
+    }
+
+    func getBolusData() -> [MainViewController.bolusGraphStruct] {
+        guard let mainVC = mainViewController else { return [] }
+        let startTime = startDate.timeIntervalSince1970
+        let endTime = endDate.timeIntervalSince1970
+        return mainVC.statsBolusData.filter { $0.date >= startTime && $0.date <= endTime }
+    }
+
+    func getSMBData() -> [MainViewController.bolusGraphStruct] {
+        guard let mainVC = mainViewController else { return [] }
+        let startTime = startDate.timeIntervalSince1970
+        let endTime = endDate.timeIntervalSince1970
+        return mainVC.statsSMBData.filter { $0.date >= startTime && $0.date <= endTime }
+    }
+
+    func getCarbData() -> [MainViewController.carbGraphStruct] {
+        guard let mainVC = mainViewController else { return [] }
+        let startTime = startDate.timeIntervalSince1970
+        let endTime = endDate.timeIntervalSince1970
+        return mainVC.statsCarbData.filter { $0.date >= startTime && $0.date <= endTime }
+    }
+
+    func getBasalData() -> [MainViewController.basalGraphStruct] {
+        guard let mainVC = mainViewController else { return [] }
+        let startTime = startDate.timeIntervalSince1970
+        let endTime = endDate.timeIntervalSince1970
+        return mainVC.statsBasalData.filter { $0.date >= startTime && $0.date <= endTime }
+    }
+
+    func getBasalProfile() -> [MainViewController.basalProfileStruct] {
+        guard let mainVC = mainViewController else { return [] }
+        return mainVC.basalProfile
+    }
+
+    func getTempBasalData() -> [TempBasalEntry] {
+        let startTime = startDate.timeIntervalSince1970
+        let endTime = endDate.timeIntervalSince1970
+        return tempBasalEntries.filter { $0.startTime >= startTime && $0.startTime <= endTime }
+    }
+
+    /// Calculate data availability for the current date range
+    func getDataAvailability() -> DataAvailabilityInfo {
+        let bgData = getBGData()
+        return DataAvailabilityCalculator.calculateAvailability(
+            bgData: bgData,
+            startDate: startDate,
+            endDate: endDate
+        )
+    }
+}

+ 132 - 0
LoopFollow/Stats/TIR/TIRCalculator.swift

@@ -0,0 +1,132 @@
+// LoopFollow
+// TIRCalculator.swift
+
+import Foundation
+
+class TIRCalculator {
+    static func calculate(bgData: [ShareGlucoseData], useTightRange: Bool = false) -> [TIRDataPoint] {
+        guard !bgData.isEmpty else { return [] }
+
+        let veryLowThreshold = 54.0
+        let lowThreshold = 70.0
+        let highThreshold = useTightRange ? 140.0 : 180.0
+        let veryHighThreshold = 250.0
+        var periodData: [TIRPeriod: [Double]] = [:]
+        let calendar = dateTimeUtils.displayCalendar()
+
+        for reading in bgData {
+            let date = Date(timeIntervalSince1970: reading.date)
+            let components = calendar.dateComponents([.hour], from: date)
+            let hour = components.hour ?? 0
+
+            let glucose = Double(reading.sgv)
+
+            var period: TIRPeriod?
+            if let hourRange = TIRPeriod.night.hourRange, hour >= hourRange.start, hour < hourRange.end {
+                period = .night
+            } else if let hourRange = TIRPeriod.morning.hourRange, hour >= hourRange.start, hour < hourRange.end {
+                period = .morning
+            } else if let hourRange = TIRPeriod.day.hourRange, hour >= hourRange.start, hour < hourRange.end {
+                period = .day
+            } else if let hourRange = TIRPeriod.evening.hourRange, hour >= hourRange.start, hour < hourRange.end {
+                period = .evening
+            }
+
+            if let period = period {
+                if periodData[period] == nil {
+                    periodData[period] = []
+                }
+                periodData[period]?.append(glucose)
+            }
+        }
+
+        var tirPoints: [TIRDataPoint] = []
+
+        for period in [TIRPeriod.night, .morning, .day, .evening] {
+            guard let readings = periodData[period], !readings.isEmpty else {
+                tirPoints.append(TIRDataPoint(
+                    period: period,
+                    veryLow: 0.0,
+                    low: 0.0,
+                    inRange: 0.0,
+                    high: 0.0,
+                    veryHigh: 0.0
+                ))
+                continue
+            }
+
+            let percentages = calculatePercentages(readings: readings,
+                                                   veryLowThreshold: veryLowThreshold,
+                                                   lowThreshold: lowThreshold,
+                                                   highThreshold: highThreshold,
+                                                   veryHighThreshold: veryHighThreshold)
+
+            tirPoints.append(TIRDataPoint(
+                period: period,
+                veryLow: percentages.veryLow,
+                low: percentages.low,
+                inRange: percentages.inRange,
+                high: percentages.high,
+                veryHigh: percentages.veryHigh
+            ))
+        }
+
+        let allReadings = bgData.map { Double($0.sgv) }
+        let averagePercentages = calculatePercentages(readings: allReadings,
+                                                      veryLowThreshold: veryLowThreshold,
+                                                      lowThreshold: lowThreshold,
+                                                      highThreshold: highThreshold,
+                                                      veryHighThreshold: veryHighThreshold)
+
+        tirPoints.append(TIRDataPoint(
+            period: .average,
+            veryLow: averagePercentages.veryLow,
+            low: averagePercentages.low,
+            inRange: averagePercentages.inRange,
+            high: averagePercentages.high,
+            veryHigh: averagePercentages.veryHigh
+        ))
+
+        return tirPoints
+    }
+
+    private static func calculatePercentages(readings: [Double],
+                                             veryLowThreshold: Double,
+                                             lowThreshold: Double,
+                                             highThreshold: Double,
+                                             veryHighThreshold: Double) -> (veryLow: Double, low: Double, inRange: Double, high: Double, veryHigh: Double)
+    {
+        let total = Double(readings.count)
+        guard total > 0 else {
+            return (0.0, 0.0, 0.0, 0.0, 0.0)
+        }
+
+        var veryLowCount = 0
+        var lowCount = 0
+        var inRangeCount = 0
+        var highCount = 0
+        var veryHighCount = 0
+
+        for glucose in readings {
+            if glucose < veryLowThreshold {
+                veryLowCount += 1
+            } else if glucose < lowThreshold {
+                lowCount += 1
+            } else if glucose > veryHighThreshold {
+                veryHighCount += 1
+            } else if glucose > highThreshold {
+                highCount += 1
+            } else {
+                inRangeCount += 1
+            }
+        }
+
+        return (
+            veryLow: (Double(veryLowCount) / total) * 100.0,
+            low: (Double(lowCount) / total) * 100.0,
+            inRange: (Double(inRangeCount) / total) * 100.0,
+            high: (Double(highCount) / total) * 100.0,
+            veryHigh: (Double(veryHighCount) / total) * 100.0
+        )
+    }
+}

+ 36 - 0
LoopFollow/Stats/TIR/TIRDataPoint.swift

@@ -0,0 +1,36 @@
+// LoopFollow
+// TIRDataPoint.swift
+
+import Foundation
+
+struct TIRDataPoint {
+    let period: TIRPeriod
+    let veryLow: Double
+    let low: Double
+    let inRange: Double
+    let high: Double
+    let veryHigh: Double
+}
+
+enum TIRPeriod: String, CaseIterable {
+    case night = "Night"
+    case morning = "Morning"
+    case day = "Day"
+    case evening = "Evening"
+    case average = "Average"
+
+    var hourRange: (start: Int, end: Int)? {
+        switch self {
+        case .night:
+            return (0, 6)
+        case .morning:
+            return (6, 12)
+        case .day:
+            return (12, 18)
+        case .evening:
+            return (18, 24)
+        case .average:
+            return nil
+        }
+    }
+}

+ 101 - 0
LoopFollow/Stats/TIR/TIRGraphView.swift

@@ -0,0 +1,101 @@
+// LoopFollow
+// TIRGraphView.swift
+
+import Charts
+import SwiftUI
+import UIKit
+
+struct TIRGraphView: UIViewRepresentable {
+    let tirData: [TIRDataPoint]
+
+    func makeCoordinator() -> Coordinator {
+        Coordinator()
+    }
+
+    func makeUIView(context _: Context) -> UIView {
+        let containerView = NonInteractiveContainerView()
+        containerView.backgroundColor = .systemBackground
+
+        let chartView = BarChartView()
+        chartView.backgroundColor = .systemBackground
+        chartView.rightAxis.enabled = false
+        chartView.leftAxis.enabled = true
+        chartView.xAxis.labelPosition = .bottom
+        chartView.xAxis.granularity = 1.0
+        chartView.leftAxis.axisMinimum = 0.0
+        chartView.leftAxis.axisMaximum = 100.0
+        chartView.leftAxis.valueFormatter = PercentageAxisValueFormatter()
+        chartView.leftAxis.labelCount = 5
+        chartView.rightAxis.drawGridLinesEnabled = false
+        chartView.leftAxis.drawGridLinesEnabled = true
+        chartView.leftAxis.gridLineDashLengths = [5, 5]
+        chartView.xAxis.drawGridLinesEnabled = false
+        chartView.legend.enabled = false
+        chartView.chartDescription.enabled = false
+        chartView.isUserInteractionEnabled = false
+
+        containerView.addSubview(chartView)
+        chartView.translatesAutoresizingMaskIntoConstraints = false
+        NSLayoutConstraint.activate([
+            chartView.topAnchor.constraint(equalTo: containerView.topAnchor),
+            chartView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
+            chartView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
+            chartView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
+        ])
+
+        return containerView
+    }
+
+    class Coordinator {}
+
+    func updateUIView(_ containerView: UIView, context _: Context) {
+        guard let chartView = containerView.subviews.first as? BarChartView else { return }
+        guard !tirData.isEmpty else { return }
+
+        var dataEntries: [BarChartDataEntry] = []
+        var xAxisLabels: [String] = []
+
+        for (index, point) in tirData.enumerated() {
+            let entry = BarChartDataEntry(
+                x: Double(index),
+                yValues: [
+                    point.veryLow,
+                    point.low,
+                    point.inRange,
+                    point.high,
+                    point.veryHigh,
+                ]
+            )
+            dataEntries.append(entry)
+            xAxisLabels.append(point.period.rawValue)
+        }
+
+        let dataSet = BarChartDataSet(entries: dataEntries, label: "Time in Range")
+        dataSet.colors = [
+            UIColor.systemRed.withAlphaComponent(0.8),
+            UIColor.systemRed.withAlphaComponent(0.5),
+            UIColor.systemGreen.withAlphaComponent(0.7),
+            UIColor.systemYellow.withAlphaComponent(0.7),
+            UIColor.systemOrange.withAlphaComponent(0.7),
+        ]
+        dataSet.stackLabels = ["Very Low", "Low", "In Range", "High", "Very High"]
+        dataSet.drawValuesEnabled = false
+
+        let data = BarChartData(dataSet: dataSet)
+        data.barWidth = 0.6
+
+        chartView.data = data
+
+        chartView.xAxis.valueFormatter = IndexAxisValueFormatter(values: xAxisLabels)
+        chartView.xAxis.labelRotationAngle = 0
+        chartView.xAxis.labelCount = xAxisLabels.count
+
+        chartView.notifyDataSetChanged()
+    }
+}
+
+class PercentageAxisValueFormatter: AxisValueFormatter {
+    func stringForValue(_ value: Double, axis _: AxisBase?) -> String {
+        return String(format: "%.0f%%", value)
+    }
+}

+ 166 - 0
LoopFollow/Stats/TIR/TIRView.swift

@@ -0,0 +1,166 @@
+// LoopFollow
+// TIRView.swift
+
+import SwiftUI
+
+struct TIRView: View {
+    @ObservedObject var viewModel: TIRViewModel
+
+    var body: some View {
+        Button(action: {
+            viewModel.toggleTIRMode()
+        }) {
+            ZStack(alignment: .topTrailing) {
+                VStack(alignment: .leading, spacing: 12) {
+                    HStack {
+                        Text(viewModel.showTITR ? "Time in Tight Range" : "Time in Range")
+                            .font(.caption)
+                            .foregroundColor(.secondary)
+                        Spacer()
+                        if let inRangeValue = viewModel.tirData.first(where: { $0.period == .average })?.inRange {
+                            Text(formatRange(inRangeValue))
+                                .font(.caption)
+                                .foregroundColor(.secondary)
+                        }
+                    }
+
+                    if !viewModel.tirData.isEmpty {
+                        TIRGraphView(tirData: viewModel.tirData)
+                            .frame(height: 250)
+                            .allowsHitTesting(false)
+                            .clipped()
+
+                        VStack(alignment: .leading, spacing: 8) {
+                            if let average = viewModel.tirData.first(where: { $0.period == .average }) {
+                                Text("Cutoffs in \(Storage.shared.units.value)")
+                                    .foregroundColor(.secondary)
+
+                                TIRLegendItem(
+                                    color: .orange,
+                                    label: "Very High (\(veryHighCutoffText))",
+                                    percentage: average.veryHigh
+                                )
+                                TIRLegendItem(
+                                    color: .yellow,
+                                    label: "High (\(highCutoffText))",
+                                    percentage: average.high
+                                )
+                                TIRLegendItem(
+                                    color: .green,
+                                    label: "In Range (\(inRangeCutoffText))",
+                                    percentage: average.inRange
+                                )
+                                TIRLegendItem(
+                                    color: .red.opacity(0.5),
+                                    label: "Low (\(lowCutoffText))",
+                                    percentage: average.low
+                                )
+                                TIRLegendItem(
+                                    color: .red.opacity(0.8),
+                                    label: "Very Low (\(veryLowCutoffText))",
+                                    percentage: average.veryLow
+                                )
+                            }
+                        }
+                        .font(.caption2)
+                    } else {
+                        Text("No data available")
+                            .font(.caption)
+                            .foregroundColor(.secondary)
+                            .frame(height: 250)
+                    }
+                }
+                .frame(maxWidth: .infinity, alignment: .leading)
+                .padding()
+
+                Image(systemName: "chevron.up.chevron.down")
+                    .font(.caption2)
+                    .foregroundColor(.secondary.opacity(0.5))
+                    .padding(8)
+            }
+            .background(Color(.systemGray6))
+            .cornerRadius(12)
+        }
+        .buttonStyle(PlainButtonStyle())
+    }
+
+    private func formatRange(_: Double) -> String {
+        let lowThreshold: Double
+        let highThreshold: Double
+
+        if Storage.shared.units.value == "mg/dL" {
+            lowThreshold = 70.0
+            highThreshold = viewModel.showTITR ? 140.0 : 180.0
+        } else {
+            lowThreshold = 3.9
+            highThreshold = viewModel.showTITR ? 7.8 : 10.0
+        }
+
+        return String(format: "%.1f – %.1f %@", lowThreshold, highThreshold, Storage.shared.units.value)
+    }
+
+    private var veryLowCutoffText: String {
+        "< \(formatThreshold(veryLowThreshold))"
+    }
+
+    private var lowCutoffText: String {
+        "\(formatThreshold(veryLowThreshold))–<\(formatThreshold(lowThreshold))"
+    }
+
+    private var inRangeCutoffText: String {
+        "\(formatThreshold(lowThreshold))–\(formatThreshold(highThreshold))"
+    }
+
+    private var highCutoffText: String {
+        ">\(formatThreshold(highThreshold))–\(formatThreshold(veryHighThreshold))"
+    }
+
+    private var veryHighCutoffText: String {
+        "> \(formatThreshold(veryHighThreshold))"
+    }
+
+    private var lowThreshold: Double {
+        Storage.shared.units.value == "mg/dL" ? 70.0 : 3.9
+    }
+
+    private var highThreshold: Double {
+        if Storage.shared.units.value == "mg/dL" {
+            return viewModel.showTITR ? 140.0 : 180.0
+        }
+        return viewModel.showTITR ? 7.8 : 10.0
+    }
+
+    private var veryLowThreshold: Double {
+        Storage.shared.units.value == "mg/dL" ? 54.0 : 3.0
+    }
+
+    private var veryHighThreshold: Double {
+        Storage.shared.units.value == "mg/dL" ? 250.0 : 13.9
+    }
+
+    private func formatThreshold(_ value: Double) -> String {
+        if Storage.shared.units.value == "mg/dL" {
+            return String(format: "%.0f", value)
+        }
+        return String(format: "%.1f", value)
+    }
+}
+
+struct TIRLegendItem: View {
+    let color: Color
+    let label: String
+    let percentage: Double
+
+    var body: some View {
+        HStack(spacing: 8) {
+            Rectangle()
+                .fill(color)
+                .frame(width: 16, height: 16)
+            Text(String(format: "%.1f%%", percentage))
+                .foregroundColor(.primary)
+            Text(label)
+                .foregroundColor(.secondary)
+            Spacer()
+        }
+    }
+}

+ 36 - 0
LoopFollow/Stats/TIR/TIRViewModel.swift

@@ -0,0 +1,36 @@
+// LoopFollow
+// TIRViewModel.swift
+
+import Combine
+import Foundation
+
+class TIRViewModel: ObservableObject {
+    @Published var tirData: [TIRDataPoint] = []
+    @Published var showTITR: Bool {
+        didSet {
+            Storage.shared.showTITR.value = showTITR
+        }
+    }
+
+    private let dataService: StatsDataService
+
+    init(dataService: StatsDataService) {
+        self.dataService = dataService
+        showTITR = Storage.shared.showTITR.value
+        calculateTIR()
+    }
+
+    func calculateTIR() {
+        let bgData = dataService.getBGData()
+        tirData = TIRCalculator.calculate(bgData: bgData, useTightRange: showTITR)
+    }
+
+    func toggleTIRMode() {
+        showTITR.toggle()
+        calculateTIR()
+    }
+
+    func clearStats() {
+        tirData = []
+    }
+}

+ 23 - 2
LoopFollow/Storage/Storage+Migrate.swift

@@ -5,21 +5,41 @@ import Foundation
 
 extension Storage {
     func migrateStep3() {
+        LogManager.shared.log(category: .general, message: "Running migrateStep3 - this should only happen once!")
         let legacyForceDarkMode = StorageValue<Bool>(key: "forceDarkMode", defaultValue: true)
         if legacyForceDarkMode.exists {
             Storage.shared.appearanceMode.value = legacyForceDarkMode.value ? .dark : .system
             legacyForceDarkMode.remove()
         }
+
+        if !TabPosition.customizablePositions.contains(homePosition.value.normalized) {
+            LogManager.shared.log(category: .general, message: "migrateStep3: Setting home to position1 (was \(homePosition.value.rawValue))")
+            homePosition.value = .position1
+        }
+
+        if !TabPosition.customizablePositions.contains(snoozerPosition.value.normalized) {
+            snoozerPosition.value = .position3
+        }
+
+        if alarmsPosition.value == .more {
+            alarmsPosition.value = .menu
+        }
+        if remotePosition.value == .more {
+            remotePosition.value = .menu
+        }
+        if nightscoutPosition.value == .more {
+            nightscoutPosition.value = .menu
+        }
     }
 
     func migrateStep2() {
         // Migrate from old system to new position-based system
         if remoteType.value != .none {
             remotePosition.value = .position2
-            alarmsPosition.value = .more
+            alarmsPosition.value = .menu
         } else {
             alarmsPosition.value = .position2
-            remotePosition.value = .more
+            remotePosition.value = .menu
         }
         nightscoutPosition.value = .position4
     }
@@ -67,6 +87,7 @@ extension Storage {
 
         let legacyForceDarkMode = UserDefaultsValue<Bool>(key: "forceDarkMode", default: true)
         if legacyForceDarkMode.exists {
+            // Migrate from Bool to AppearanceMode: true -> .dark, false -> .system
             Storage.shared.appearanceMode.value = legacyForceDarkMode.value ? .dark : .system
             legacyForceDarkMode.setNil(key: "forceDarkMode")
         }

+ 59 - 1
LoopFollow/Storage/Storage.swift

@@ -164,9 +164,14 @@ class Storage {
     var lastLoopingChecked = StorageValue<Date?>(key: "lastLoopingChecked", defaultValue: nil)
     var lastBGChecked = StorageValue<Date?>(key: "lastBGChecked", defaultValue: nil)
 
+    // Tab positions - which position each item is in (positions 1-4 are customizable, 5 is always Menu)
+    var homePosition = StorageValue<TabPosition>(key: "homePosition", defaultValue: .position1)
     var alarmsPosition = StorageValue<TabPosition>(key: "alarmsPosition", defaultValue: .position2)
-    var remotePosition = StorageValue<TabPosition>(key: "remotePosition", defaultValue: .more)
+    var snoozerPosition = StorageValue<TabPosition>(key: "snoozerPosition", defaultValue: .position3)
     var nightscoutPosition = StorageValue<TabPosition>(key: "nightscoutPosition", defaultValue: .position4)
+    var remotePosition = StorageValue<TabPosition>(key: "remotePosition", defaultValue: .menu)
+    var statisticsPosition = StorageValue<TabPosition>(key: "statisticsPosition", defaultValue: .menu)
+    var treatmentsPosition = StorageValue<TabPosition>(key: "treatmentsPosition", defaultValue: .menu)
 
     var loopAPNSQrCodeURL = StorageValue<String>(key: "loopAPNSQrCodeURL", defaultValue: "")
 
@@ -175,7 +180,60 @@ class Storage {
 
     var bolusIncrement = SecureStorageValue<HKQuantity>(key: "bolusIncrement", defaultValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05))
     var bolusIncrementDetected = StorageValue<Bool>(key: "bolusIncrementDetected", defaultValue: false)
+    // Statistics display preferences
+    var showGMI = StorageValue<Bool>(key: "showGMI", defaultValue: true)
+    var showStdDev = StorageValue<Bool>(key: "showStdDev", defaultValue: true)
+    var showTITR = StorageValue<Bool>(key: "showTITR", defaultValue: false)
 
     static let shared = Storage()
     private init() {}
+
+    // MARK: - Tab Position Helpers
+
+    /// Get the position for a given tab item
+    func position(for item: TabItem) -> TabPosition {
+        switch item {
+        case .home: return homePosition.value
+        case .alarms: return alarmsPosition.value
+        case .remote: return remotePosition.value
+        case .nightscout: return nightscoutPosition.value
+        case .snoozer: return snoozerPosition.value
+        case .stats: return statisticsPosition.value
+        case .treatments: return treatmentsPosition.value
+        }
+    }
+
+    /// Set the position for a given tab item
+    func setPosition(_ position: TabPosition, for item: TabItem) {
+        switch item {
+        case .home: homePosition.value = position
+        case .alarms: alarmsPosition.value = position
+        case .remote: remotePosition.value = position
+        case .nightscout: nightscoutPosition.value = position
+        case .snoozer: snoozerPosition.value = position
+        case .stats: statisticsPosition.value = position
+        case .treatments: treatmentsPosition.value = position
+        }
+    }
+
+    /// Get the tab item at a specific position (nil if no item at that position)
+    func tabItem(at position: TabPosition) -> TabItem? {
+        for item in TabItem.allCases {
+            // Use normalized comparison to handle legacy values (.more, .disabled -> .menu)
+            if self.position(for: item).normalized == position.normalized {
+                return item
+            }
+        }
+        return nil
+    }
+
+    /// Get all items in the Menu (position 5)
+    func itemsInMenu() -> [TabItem] {
+        TabItem.allCases.filter { position(for: $0).normalized == .menu }
+    }
+
+    /// Get items ordered by their position in the tab bar (positions 1-4)
+    func orderedTabBarItems() -> [TabItem] {
+        TabPosition.customizablePositions.compactMap { tabItem(at: $0) }
+    }
 }

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1438 - 0
LoopFollow/Treatments/TreatmentsView.swift


+ 264 - 101
LoopFollow/ViewControllers/MainViewController.swift

@@ -15,12 +15,9 @@ func IsNightscoutEnabled() -> Bool {
     return !Storage.shared.url.value.isEmpty
 }
 
-private enum SecondTab {
-    case remote
-    case alarms
-}
-
 class MainViewController: UIViewController, UITableViewDataSource, ChartViewDelegate, UNUserNotificationCenterDelegate, UIScrollViewDelegate {
+    var isPresentedAsModal: Bool = false
+
     @IBOutlet var BGText: UILabel!
     @IBOutlet var DeltaText: UILabel!
     @IBOutlet var DirectionText: UILabel!
@@ -76,6 +73,13 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
     var bolusData: [bolusGraphStruct] = []
     var smbData: [bolusGraphStruct] = []
     var carbData: [carbGraphStruct] = []
+
+    // Stats-specific data storage (can hold up to 30 days)
+    var statsBGData: [ShareGlucoseData] = []
+    var statsBolusData: [bolusGraphStruct] = []
+    var statsSMBData: [bolusGraphStruct] = []
+    var statsCarbData: [carbGraphStruct] = []
+    var statsBasalData: [basalGraphStruct] = []
     var overrideGraphData: [DataStructs.overrideStruct] = []
     var tempTargetGraphData: [DataStructs.tempTargetStruct] = []
     var predictionData: [ShareGlucoseData] = []
@@ -121,6 +125,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
     let contactImageUpdater = ContactImageUpdater()
 
     private var cancellables = Set<AnyCancellable>()
+    private var isViewHierarchyReady = false
 
     // Loading state management
     private var loadingOverlay: UIView?
@@ -213,8 +218,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         refreshControl = UIRefreshControl()
         refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged)
         refreshScrollView.addSubview(refreshControl)
-
-        // Add this line to prevent scrolling in other directions
         refreshScrollView.alwaysBounceVertical = true
 
         refreshScrollView.delegate = self
@@ -246,9 +249,12 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
             .receive(on: DispatchQueue.main)
             .compactMap { $0 }
             .sink { [weak self] _ in
-                if let snoozerIndex = self?.getSnoozerTabIndex() {
-                    self?.tabBarController?.selectedIndex = snoozerIndex
-                }
+                guard let self = self,
+                      let tabBarController = self.tabBarController,
+                      let vcs = tabBarController.viewControllers, !vcs.isEmpty,
+                      let snoozerIndex = self.getSnoozerTabIndex(),
+                      snoozerIndex < vcs.count else { return }
+                tabBarController.selectedIndex = snoozerIndex
             }
             .store(in: &cancellables)
 
@@ -302,33 +308,42 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
             }
             .store(in: &cancellables)
 
-        Storage.shared.speakBG.$value
+        Storage.shared.graphTimeZoneEnabled.$value
             .receive(on: DispatchQueue.main)
             .sink { [weak self] _ in
-                self?.updateQuickActions()
+                self?.infoTable.reloadData()
             }
             .store(in: &cancellables)
 
-        Storage.shared.alarmsPosition.$value
+        Storage.shared.graphTimeZoneIdentifier.$value
             .receive(on: DispatchQueue.main)
             .sink { [weak self] _ in
-                self?.setupTabBar()
+                self?.infoTable.reloadData()
             }
             .store(in: &cancellables)
 
-        Storage.shared.remotePosition.$value
+        Storage.shared.speakBG.$value
             .receive(on: DispatchQueue.main)
             .sink { [weak self] _ in
-                self?.setupTabBar()
+                self?.updateQuickActions()
             }
             .store(in: &cancellables)
 
-        Storage.shared.nightscoutPosition.$value
-            .receive(on: DispatchQueue.main)
-            .sink { [weak self] _ in
-                self?.setupTabBar()
-            }
-            .store(in: &cancellables)
+        // Observe all tab position changes with debouncing to handle batch updates
+        Publishers.MergeMany(
+            Storage.shared.homePosition.$value.map { _ in () }.eraseToAnyPublisher(),
+            Storage.shared.alarmsPosition.$value.map { _ in () }.eraseToAnyPublisher(),
+            Storage.shared.remotePosition.$value.map { _ in () }.eraseToAnyPublisher(),
+            Storage.shared.nightscoutPosition.$value.map { _ in () }.eraseToAnyPublisher(),
+            Storage.shared.snoozerPosition.$value.map { _ in () }.eraseToAnyPublisher(),
+            Storage.shared.statisticsPosition.$value.map { _ in () }.eraseToAnyPublisher(),
+            Storage.shared.treatmentsPosition.$value.map { _ in () }.eraseToAnyPublisher()
+        )
+        .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
+        .sink { [weak self] _ in
+            self?.setupTabBar()
+        }
+        .store(in: &cancellables)
 
         Storage.shared.url.$value
             .receive(on: DispatchQueue.main)
@@ -405,7 +420,13 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
             .store(in: &cancellables)
 
         updateQuickActions()
-        setupTabBar()
+
+        // Delay initial tab setup to ensure view hierarchy is ready
+        // This prevents crashes when trying to modify tabs during viewWillAppear
+        DispatchQueue.main.async { [weak self] in
+            self?.isViewHierarchyReady = true
+            self?.setupTabBar()
+        }
 
         speechSynthesizer.delegate = self
 
@@ -520,78 +541,140 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
     }
 
     private func setupTabBar() {
-        guard let tabBarController = tabBarController else { return }
-
-        // Store current selection before making changes
-        let currentSelectedIndex = tabBarController.selectedIndex
-
-        // Check if we need to handle More tab disappearing
-        let wasInMoreTab = currentSelectedIndex == 4 &&
-            tabBarController.viewControllers?.last is MoreMenuViewController
-        let willHaveMoreTab = hasItemsInMore()
-
-        // If currently in More tab and it's going away, we need to handle this carefully
-        if wasInMoreTab, !willHaveMoreTab {
-            // First, dismiss any modals that might be open
-            if let presented = tabBarController.presentedViewController {
-                presented.dismiss(animated: false) { [weak self] in
-                    // After dismissal, rebuild tabs with home selected
-                    self?.rebuildTabs(tabBarController: tabBarController,
-                                      willHaveMoreTab: willHaveMoreTab,
-                                      selectedIndex: 0)
-                }
-                return
+        guard isViewHierarchyReady else { return }
+
+        guard !isPresentedAsModal else { return }
+
+        var tbc = tabBarController
+        if tbc == nil {
+            if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+               let window = windowScene.windows.first,
+               let rootVC = window.rootViewController as? UITabBarController
+            {
+                tbc = rootVC
             }
         }
 
-        // For all other cases, rebuild tabs normally
-        rebuildTabs(tabBarController: tabBarController,
-                    willHaveMoreTab: willHaveMoreTab,
-                    selectedIndex: wasInMoreTab && !willHaveMoreTab ? 0 : currentSelectedIndex)
+        guard let tabBarController = tbc else { return }
+
+        // If settings modal is presented, skip rebuild - it will happen when settings is dismissed
+        if tabBarController.presentedViewController != nil {
+            return
+        }
+
+        rebuildTabs(tabBarController: tabBarController)
     }
 
-    private func rebuildTabs(tabBarController: UITabBarController,
-                             willHaveMoreTab: Bool,
-                             selectedIndex: Int)
-    {
+    /// Static method to rebuild tabs from anywhere in the app
+    /// This is useful when the MainViewController instance may not be in the tab bar
+    static func rebuildTabsIfNeeded() {
+        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+              let window = windowScene.windows.first,
+              let tabBarController = window.rootViewController as? UITabBarController
+        else { return }
+
+        let previousSelectedIndex = tabBarController.selectedIndex
+
         let storyboard = UIStoryboard(name: "Main", bundle: nil)
         var viewControllers: [UIViewController] = []
 
-        // Tab 0 - Home (always)
-        viewControllers.append(self)
+        let orderedItems = Storage.shared.orderedTabBarItems()
 
-        // Tab 1 - Dynamic based on what's assigned to position2
-        if let vc = createViewController(for: .position2, storyboard: storyboard) {
-            viewControllers.append(vc)
+        for (index, item) in orderedItems.prefix(4).enumerated() {
+            let position = TabPosition.customizablePositions[index]
+            if let vc = createViewControllerStatic(for: item, position: position, storyboard: storyboard) {
+                viewControllers.append(vc)
+            }
         }
 
-        // Tab 2 - Snoozer (always)
-        let snoozerVC = storyboard.instantiateViewController(withIdentifier: "SnoozerViewController")
-        snoozerVC.tabBarItem = UITabBarItem(title: "Snoozer", image: UIImage(systemName: "zzz"), tag: 2)
-        viewControllers.append(snoozerVC)
+        let menuVC = MoreMenuViewController()
+        menuVC.tabBarItem = UITabBarItem(title: "Menu", image: UIImage(systemName: "line.3.horizontal"), tag: 4)
+        viewControllers.append(menuVC)
 
-        // Tab 3 - Dynamic based on what's assigned to position4
-        if let vc = createViewController(for: .position4, storyboard: storyboard) {
-            viewControllers.append(vc)
+        if let presented = tabBarController.presentedViewController {
+            presented.dismiss(animated: false) {
+                tabBarController.setViewControllers(viewControllers, animated: false)
+                guard !viewControllers.isEmpty else { return }
+                let targetIndex = min(previousSelectedIndex, viewControllers.count - 1)
+                tabBarController.selectedIndex = targetIndex
+            }
+        } else {
+            tabBarController.setViewControllers(viewControllers, animated: false)
+            guard !viewControllers.isEmpty else { return }
+            let targetIndex = min(previousSelectedIndex, viewControllers.count - 1)
+            tabBarController.selectedIndex = targetIndex
         }
+    }
 
-        // Tab 4 - Settings or More
-        if willHaveMoreTab {
-            let moreVC = MoreMenuViewController()
-            moreVC.tabBarItem = UITabBarItem(title: "More", image: UIImage(systemName: "ellipsis"), tag: 4)
-            viewControllers.append(moreVC)
-        } else {
-            let settingsVC = SettingsViewController()
-            settingsVC.tabBarItem = UITabBarItem(title: "Settings", image: UIImage(systemName: "gear"), tag: 4)
-            viewControllers.append(settingsVC)
+    /// Static helper to create view controllers
+    private static func createViewControllerStatic(for item: TabItem, position: TabPosition, storyboard: UIStoryboard) -> UIViewController? {
+        let tag = position.tabIndex ?? 0
+
+        switch item {
+        case .home:
+            guard let mainVC = storyboard.instantiateViewController(withIdentifier: "MainViewController") as? MainViewController else {
+                return nil
+            }
+            mainVC.tabBarItem = UITabBarItem(title: "Home", image: UIImage(systemName: item.icon), tag: tag)
+            return mainVC
+
+        case .alarms:
+            let vc = storyboard.instantiateViewController(withIdentifier: "AlarmViewController")
+            vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
+            return vc
+
+        case .remote:
+            let vc = storyboard.instantiateViewController(withIdentifier: "RemoteViewController")
+            vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
+            return vc
+
+        case .nightscout:
+            let vc = storyboard.instantiateViewController(withIdentifier: "NightscoutViewController")
+            vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
+            return vc
+
+        case .snoozer:
+            let vc = storyboard.instantiateViewController(withIdentifier: "SnoozerViewController")
+            vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
+            return vc
+
+        case .treatments:
+            let treatmentsVC = UIHostingController(rootView: TreatmentsView())
+            treatmentsVC.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
+            return treatmentsVC
+
+        case .stats:
+            // You may need to provide a MainViewController or view model as needed
+            let statsVC = UIHostingController(rootView: AggregatedStatsView(viewModel: AggregatedStatsViewModel(mainViewController: nil)))
+            statsVC.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
+            return statsVC
+        }
+    }
+
+    private func rebuildTabs(tabBarController: UITabBarController) {
+        let previousSelectedIndex = tabBarController.selectedIndex
+
+        let storyboard = UIStoryboard(name: "Main", bundle: nil)
+        var viewControllers: [UIViewController] = []
+
+        let orderedItems = Storage.shared.orderedTabBarItems()
+
+        for (index, item) in orderedItems.prefix(4).enumerated() {
+            let position = TabPosition.customizablePositions[index]
+            if let vc = createViewController(for: item, position: position, storyboard: storyboard) {
+                viewControllers.append(vc)
+            }
         }
 
-        // Update view controllers without animation to prevent glitches
+        let menuVC = MoreMenuViewController()
+        menuVC.tabBarItem = UITabBarItem(title: "Menu", image: UIImage(systemName: "line.3.horizontal"), tag: 4)
+        viewControllers.append(menuVC)
+
         tabBarController.setViewControllers(viewControllers, animated: false)
 
-        // Restore selection if valid, otherwise default to home
-        let safeIndex = min(selectedIndex, viewControllers.count - 1)
-        tabBarController.selectedIndex = max(0, safeIndex)
+        guard !viewControllers.isEmpty else { return }
+        let targetIndex = min(previousSelectedIndex, viewControllers.count - 1)
+        tabBarController.selectedIndex = targetIndex
 
         updateNightscoutTabState()
     }
@@ -609,32 +692,82 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         return nil
     }
 
-    private func createViewController(for position: TabPosition, storyboard: UIStoryboard) -> UIViewController? {
-        if Storage.shared.alarmsPosition.value == position {
+    private func createViewController(for item: TabItem, position: TabPosition, storyboard: UIStoryboard) -> UIViewController? {
+        let tag = position.tabIndex ?? 0
+
+        switch item {
+        case .home:
+            tabBarItem = UITabBarItem(title: "Home", image: UIImage(systemName: item.icon), tag: tag)
+            return self
+
+        case .alarms:
             let vc = storyboard.instantiateViewController(withIdentifier: "AlarmViewController")
-            vc.tabBarItem = UITabBarItem(title: "Alarms", image: UIImage(systemName: "alarm"), tag: position == .position2 ? 1 : 3)
+            vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
             return vc
-        }
 
-        if Storage.shared.remotePosition.value == position {
+        case .remote:
             let vc = storyboard.instantiateViewController(withIdentifier: "RemoteViewController")
-            vc.tabBarItem = UITabBarItem(title: "Remote", image: UIImage(systemName: "antenna.radiowaves.left.and.right"), tag: position == .position2 ? 1 : 3)
+            vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
             return vc
-        }
 
-        if Storage.shared.nightscoutPosition.value == position {
+        case .nightscout:
             let vc = storyboard.instantiateViewController(withIdentifier: "NightscoutViewController")
-            vc.tabBarItem = UITabBarItem(title: "Nightscout", image: UIImage(systemName: "safari"), tag: position == .position2 ? 1 : 3)
+            vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
             return vc
-        }
 
-        return nil
+        case .snoozer:
+            let vc = storyboard.instantiateViewController(withIdentifier: "SnoozerViewController")
+            vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
+            return vc
+
+        case .treatments:
+            let treatmentsVC = UIHostingController(rootView: TreatmentsView())
+            treatmentsVC.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
+            return treatmentsVC
+
+        case .stats:
+            let statsVC = UIHostingController(rootView: AggregatedStatsView(viewModel: AggregatedStatsViewModel(mainViewController: self)))
+            statsVC.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
+            return statsVC
+        }
     }
 
-    private func hasItemsInMore() -> Bool {
-        return Storage.shared.alarmsPosition.value == .more ||
-            Storage.shared.remotePosition.value == .more ||
-            Storage.shared.nightscoutPosition.value == .more
+    private func createComingSoonViewController(title: String, icon: String) -> UIViewController {
+        let vc = UIViewController()
+        vc.view.backgroundColor = .systemBackground
+
+        let stackView = UIStackView()
+        stackView.axis = .vertical
+        stackView.alignment = .center
+        stackView.spacing = 16
+        stackView.translatesAutoresizingMaskIntoConstraints = false
+
+        let imageView = UIImageView(image: UIImage(systemName: icon))
+        imageView.tintColor = .secondaryLabel
+        imageView.contentMode = .scaleAspectFit
+        imageView.translatesAutoresizingMaskIntoConstraints = false
+        NSLayoutConstraint.activate([
+            imageView.widthAnchor.constraint(equalToConstant: 60),
+            imageView.heightAnchor.constraint(equalToConstant: 60),
+        ])
+
+        let titleLabel = UILabel()
+        titleLabel.text = title
+        titleLabel.font = .preferredFont(forTextStyle: .title1)
+        titleLabel.textColor = .label
+
+        stackView.addArrangedSubview(imageView)
+        stackView.addArrangedSubview(titleLabel)
+
+        vc.view.addSubview(stackView)
+        NSLayoutConstraint.activate([
+            stackView.centerXAnchor.constraint(equalTo: vc.view.centerXAnchor),
+            stackView.centerYAnchor.constraint(equalTo: vc.view.centerYAnchor),
+        ])
+
+        vc.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
+
+        return vc
     }
 
     // Update the Home Screen Quick Action for toggling the "Speak BG" feature based on the current speakBG setting.
@@ -705,6 +838,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
 
     override func viewWillAppear(_: Bool) {
         UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value
+        infoTable.reloadData()
 
         if Observable.shared.chartSettingsChanged.value {
             updateBGGraphSettings()
@@ -716,18 +850,42 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         }
     }
 
+    private var timeZoneOverrideInfoValue: String? {
+        guard Storage.shared.graphTimeZoneEnabled.value,
+              let overrideTimeZone = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value)
+        else {
+            return nil
+        }
+
+        return overrideTimeZone.identifier
+    }
+
     // Info Table Functions
     func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
         guard let infoManager = infoManager else {
             return 0
         }
-        return infoManager.numberOfRows()
+        let overrideRowCount = timeZoneOverrideInfoValue == nil ? 0 : 1
+        return infoManager.numberOfRows() + overrideRowCount
     }
 
     func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
         let cell = tableView.dequeueReusableCell(withIdentifier: "LabelCell", for: indexPath)
 
-        if let values = infoManager.dataForIndexPath(indexPath) {
+        if indexPath.row == 0, let timeZoneOverrideInfoValue {
+            cell.textLabel?.text = "Time Zone"
+            cell.detailTextLabel?.text = timeZoneOverrideInfoValue
+            return cell
+        }
+
+        let adjustedIndexPath: IndexPath
+        if timeZoneOverrideInfoValue != nil {
+            adjustedIndexPath = IndexPath(row: indexPath.row - 1, section: indexPath.section)
+        } else {
+            adjustedIndexPath = indexPath
+        }
+
+        if let values = infoManager.dataForIndexPath(adjustedIndexPath) {
             cell.textLabel?.text = values.name
             cell.detailTextLabel?.text = values.value
         } else {
@@ -743,7 +901,11 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         UIApplication.shared.isIdleTimerDisabled = false
 
         // We want to always come back to the home screen
-        tabBarController?.selectedIndex = 0
+        if let tabBarController = tabBarController,
+           let vcs = tabBarController.viewControllers, !vcs.isEmpty
+        {
+            tabBarController.selectedIndex = 0
+        }
 
         if Storage.shared.backgroundRefreshType.value == .silentTune {
             backgroundTask.startBackgroundTask()
@@ -1033,7 +1195,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
                 do {
                     try store.remove(i, span: EKSpan.thisEvent, commit: true)
                 } catch {
-                    print(error)
+                    LogManager.shared.log(category: .calendar, message: "Failed to remove calendar event: \(error.localizedDescription)")
                 }
             }
         }
@@ -1118,7 +1280,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         for index in currentValidIndices {
             if !sortArray.contains(index) {
                 sortArray.append(index)
-                // print("Added missing index \(index) to sortArray")
             }
         }
 
@@ -1129,14 +1290,12 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         if visibleArray.count < currentValidIndices.count {
             for i in visibleArray.count ..< currentValidIndices.count {
                 visibleArray.append(InfoType(rawValue: i)?.defaultVisible ?? false)
-                // print("Added default visibility for new index \(i)")
             }
         }
 
         // Trim excess elements if there are more than needed
         if visibleArray.count > currentValidIndices.count {
             visibleArray = Array(visibleArray.prefix(currentValidIndices.count))
-            // print("Trimmed visibleArray to match current valid indices")
         }
 
         Storage.shared.infoSort.value = sortArray
@@ -1238,10 +1397,12 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
 
         // Add a Done button
         hostingController.navigationItem.rightBarButtonItem = UIBarButtonItem(
-            barButtonSystemItem: .done,
+            image: UIImage(systemName: "checkmark"),
+            style: .plain,
             target: self,
             action: #selector(dismissModal)
         )
+        hostingController.navigationItem.rightBarButtonItem?.tintColor = .systemBlue
 
         navController.modalPresentationStyle = .pageSheet
         present(navController, animated: true)
@@ -1259,10 +1420,12 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
 
         // Add a Done button
         hostingController.navigationItem.rightBarButtonItem = UIBarButtonItem(
-            barButtonSystemItem: .done,
+            image: UIImage(systemName: "checkmark"),
+            style: .plain,
             target: self,
             action: #selector(dismissModal)
         )
+        hostingController.navigationItem.rightBarButtonItem?.tintColor = .systemBlue
 
         navController.modalPresentationStyle = .pageSheet
         present(navController, animated: true)

+ 229 - 54
LoopFollow/ViewControllers/MoreMenuViewController.swift

@@ -8,19 +8,28 @@ import UIKit
 class MoreMenuViewController: UIViewController {
     private var tableView: UITableView!
     private var cancellables = Set<AnyCancellable>()
+    private var fallbackMainViewController: MainViewController?
 
     struct MenuItem {
         let title: String
         let icon: String
+        let subtitle: String?
         let action: () -> Void
+
+        init(title: String, icon: String, subtitle: String? = nil, action: @escaping () -> Void) {
+            self.title = title
+            self.icon = icon
+            self.subtitle = subtitle
+            self.action = action
+        }
     }
 
-    private var menuItems: [MenuItem] = []
+    private var menuSections: [[MenuItem]] = []
 
     override func viewDidLoad() {
         super.viewDidLoad()
 
-        title = "More"
+        title = "Menu"
         view.backgroundColor = .systemBackground
 
         // Apply appearance mode
@@ -81,46 +90,65 @@ class MoreMenuViewController: UIViewController {
     }
 
     private func updateMenuItems() {
-        menuItems = []
-
-        // Always add Settings
-        menuItems.append(MenuItem(
-            title: "Settings",
-            icon: "gear",
-            action: { [weak self] in
-                self?.openSettings()
-            }
-        ))
+        menuSections = []
 
-        // Add items based on their positions
-        if Storage.shared.alarmsPosition.value == .more {
-            menuItems.append(MenuItem(
-                title: "Alarms",
-                icon: "alarm",
+        // Section 0: Settings (always fixed at top)
+        let settingsSection = [
+            MenuItem(
+                title: "Settings",
+                icon: "gear",
                 action: { [weak self] in
-                    self?.openAlarms()
+                    self?.openSettings()
                 }
-            ))
+            ),
+        ]
+        menuSections.append(settingsSection)
+
+        let itemsInMenu = Storage.shared.itemsInMenu()
+
+        if !itemsInMenu.isEmpty {
+            var dynamicSection: [MenuItem] = []
+            for item in itemsInMenu {
+                dynamicSection.append(MenuItem(
+                    title: item.displayName,
+                    icon: item.icon,
+                    action: { [weak self] in
+                        self?.openItem(item)
+                    }
+                ))
+            }
+            menuSections.append(dynamicSection)
         }
 
-        if Storage.shared.remotePosition.value == .more {
-            menuItems.append(MenuItem(
-                title: "Remote",
-                icon: "antenna.radiowaves.left.and.right",
+        // Section: Community
+        let communitySection = [
+            MenuItem(
+                title: "LoopFollow Facebook Group",
+                icon: "person.2.fill",
                 action: { [weak self] in
-                    self?.openRemote()
+                    self?.openFacebookGroup()
                 }
-            ))
-        }
+            ),
+        ]
+        menuSections.append(communitySection)
+    }
 
-        if Storage.shared.nightscoutPosition.value == .more {
-            menuItems.append(MenuItem(
-                title: "Nightscout",
-                icon: "safari",
-                action: { [weak self] in
-                    self?.openNightscout()
-                }
-            ))
+    private func openItem(_ item: TabItem) {
+        switch item {
+        case .home:
+            openHome()
+        case .alarms:
+            openAlarmsConfig()
+        case .remote:
+            openRemote()
+        case .nightscout:
+            openNightscout()
+        case .snoozer:
+            openSnoozer()
+        case .treatments:
+            openTreatments()
+        case .stats:
+            openAggregatedStats()
         }
     }
 
@@ -128,38 +156,38 @@ class MoreMenuViewController: UIViewController {
         let settingsVC = UIHostingController(rootView: SettingsMenuView())
         let navController = UINavigationController(rootViewController: settingsVC)
 
-        // Apply appearance mode
         let style = Storage.shared.appearanceMode.value.userInterfaceStyle
         settingsVC.overrideUserInterfaceStyle = style
         navController.overrideUserInterfaceStyle = style
 
-        // Add a close button
         settingsVC.navigationItem.rightBarButtonItem = UIBarButtonItem(
-            barButtonSystemItem: .done,
+            image: UIImage(systemName: "checkmark"),
+            style: .plain,
             target: self,
-            action: #selector(dismissModal)
+            action: #selector(dismissSettingsModal)
         )
+        settingsVC.navigationItem.rightBarButtonItem?.tintColor = .systemBlue
 
         navController.modalPresentationStyle = .fullScreen
         present(navController, animated: true)
     }
 
-    private func openAlarms() {
-        let storyboard = UIStoryboard(name: "Main", bundle: nil)
-        let alarmsVC = storyboard.instantiateViewController(withIdentifier: "AlarmViewController")
+    private func openAlarmsConfig() {
+        let alarmsVC = UIHostingController(rootView: AlarmsContainerView())
+        alarmsVC.title = "Alarms"
         let navController = UINavigationController(rootViewController: alarmsVC)
 
-        // Apply appearance mode
         let style = Storage.shared.appearanceMode.value.userInterfaceStyle
         alarmsVC.overrideUserInterfaceStyle = style
         navController.overrideUserInterfaceStyle = style
 
-        // Add a close button
         alarmsVC.navigationItem.rightBarButtonItem = UIBarButtonItem(
-            barButtonSystemItem: .done,
+            image: UIImage(systemName: "checkmark"),
+            style: .plain,
             target: self,
             action: #selector(dismissModal)
         )
+        alarmsVC.navigationItem.rightBarButtonItem?.tintColor = .systemBlue
 
         navController.modalPresentationStyle = .fullScreen
         present(navController, animated: true)
@@ -170,17 +198,17 @@ class MoreMenuViewController: UIViewController {
         let remoteVC = storyboard.instantiateViewController(withIdentifier: "RemoteViewController")
         let navController = UINavigationController(rootViewController: remoteVC)
 
-        // Apply appearance mode
         let style = Storage.shared.appearanceMode.value.userInterfaceStyle
         remoteVC.overrideUserInterfaceStyle = style
         navController.overrideUserInterfaceStyle = style
 
-        // Add a close button
         remoteVC.navigationItem.rightBarButtonItem = UIBarButtonItem(
-            barButtonSystemItem: .done,
+            image: UIImage(systemName: "checkmark"),
+            style: .plain,
             target: self,
             action: #selector(dismissModal)
         )
+        remoteVC.navigationItem.rightBarButtonItem?.tintColor = .systemBlue
 
         navController.modalPresentationStyle = .fullScreen
         present(navController, animated: true)
@@ -191,39 +219,186 @@ class MoreMenuViewController: UIViewController {
         let nightscoutVC = storyboard.instantiateViewController(withIdentifier: "NightscoutViewController")
         let navController = UINavigationController(rootViewController: nightscoutVC)
 
-        // Apply appearance mode
         let style = Storage.shared.appearanceMode.value.userInterfaceStyle
         nightscoutVC.overrideUserInterfaceStyle = style
         navController.overrideUserInterfaceStyle = style
 
-        // Add a close button
         nightscoutVC.navigationItem.rightBarButtonItem = UIBarButtonItem(
-            barButtonSystemItem: .done,
+            image: UIImage(systemName: "checkmark"),
+            style: .plain,
             target: self,
             action: #selector(dismissModal)
         )
+        nightscoutVC.navigationItem.rightBarButtonItem?.tintColor = .systemBlue
 
         navController.modalPresentationStyle = .fullScreen
         present(navController, animated: true)
     }
 
+    private func openSnoozer() {
+        let storyboard = UIStoryboard(name: "Main", bundle: nil)
+        let snoozerVC = storyboard.instantiateViewController(withIdentifier: "SnoozerViewController")
+        let navController = UINavigationController(rootViewController: snoozerVC)
+
+        let style = Storage.shared.appearanceMode.value.userInterfaceStyle
+        snoozerVC.overrideUserInterfaceStyle = style
+        navController.overrideUserInterfaceStyle = style
+
+        snoozerVC.navigationItem.rightBarButtonItem = UIBarButtonItem(
+            image: UIImage(systemName: "checkmark"),
+            style: .plain,
+            target: self,
+            action: #selector(dismissModal)
+        )
+        snoozerVC.navigationItem.rightBarButtonItem?.tintColor = .systemBlue
+
+        navController.modalPresentationStyle = .fullScreen
+        present(navController, animated: true)
+    }
+
+    private func openTreatments() {
+        let treatmentsVC = UIHostingController(rootView: TreatmentsView())
+        let navController = UINavigationController(rootViewController: treatmentsVC)
+
+        // Apply appearance mode
+        let style = Storage.shared.appearanceMode.value.userInterfaceStyle
+        treatmentsVC.overrideUserInterfaceStyle = style
+        navController.overrideUserInterfaceStyle = style
+
+        // Add a close button
+        treatmentsVC.navigationItem.rightBarButtonItem = UIBarButtonItem(
+            image: UIImage(systemName: "checkmark"),
+            style: .plain,
+            target: self,
+            action: #selector(dismissModal)
+        )
+        treatmentsVC.navigationItem.rightBarButtonItem?.tintColor = .systemBlue
+
+        navController.modalPresentationStyle = .fullScreen
+        present(navController, animated: true)
+    }
+
+    private func openAggregatedStats() {
+        guard let mainVC = getMainViewController() else {
+            presentSimpleAlert(title: "Error", message: "Unable to access data")
+            return
+        }
+
+        let statsVC = UIHostingController(
+            rootView: AggregatedStatsView(viewModel: AggregatedStatsViewModel(mainViewController: mainVC))
+        )
+        let navController = UINavigationController(rootViewController: statsVC)
+
+        // Apply appearance mode
+        let style = Storage.shared.appearanceMode.value.userInterfaceStyle
+        statsVC.overrideUserInterfaceStyle = style
+        navController.overrideUserInterfaceStyle = style
+
+        // Add a close button
+        statsVC.navigationItem.rightBarButtonItem = UIBarButtonItem(
+            image: UIImage(systemName: "checkmark"),
+            style: .plain,
+            target: self,
+            action: #selector(dismissModal)
+        )
+        statsVC.navigationItem.rightBarButtonItem?.tintColor = .systemBlue
+
+        navController.modalPresentationStyle = .fullScreen
+        present(navController, animated: true)
+    }
+
+    private func openHome() {
+        // First check if Home is in the tab bar
+        if let tabVC = tabBarController {
+            for (index, vc) in (tabVC.viewControllers ?? []).enumerated() {
+                if vc is MainViewController {
+                    // Home is in the tab bar, switch to it
+                    tabVC.selectedIndex = index
+                    return
+                }
+            }
+        }
+
+        // Home is in the menu - present the full Home screen as a modal
+        let homeModalView = HomeModalView()
+        let hostingController = UIHostingController(rootView: homeModalView)
+
+        hostingController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
+
+        hostingController.modalPresentationStyle = .fullScreen
+        present(hostingController, animated: true)
+    }
+
+    private func openFacebookGroup() {
+        if let url = URL(string: "https://www.facebook.com/groups/loopfollowlnl") {
+            UIApplication.shared.open(url)
+        }
+    }
+
+    @objc private func dismissSettingsModal() {
+        dismiss(animated: true) {
+            // Rebuild tabs after settings is dismissed to apply any tab order changes
+            MainViewController.rebuildTabsIfNeeded()
+        }
+    }
+
+    private func getMainViewController() -> MainViewController? {
+        // Try to find MainViewController in the view hierarchy
+        guard let tabBarController = tabBarController else { return nil }
+
+        for vc in tabBarController.viewControllers ?? [] {
+            if let mainVC = vc as? MainViewController {
+                return mainVC
+            }
+            if let navVC = vc as? UINavigationController,
+               let mainVC = navVC.viewControllers.first as? MainViewController
+            {
+                return mainVC
+            }
+        }
+
+        if let fallbackMainViewController {
+            return fallbackMainViewController
+        }
+
+        let storyboard = UIStoryboard(name: "Main", bundle: nil)
+        guard let mainVC = storyboard.instantiateViewController(withIdentifier: "MainViewController") as? MainViewController else {
+            return nil
+        }
+
+        mainVC.isPresentedAsModal = true
+        fallbackMainViewController = mainVC
+        return mainVC
+    }
+
     @objc private func dismissModal() {
         dismiss(animated: true)
     }
 }
 
 extension MoreMenuViewController: UITableViewDataSource, UITableViewDelegate {
-    func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
-        return menuItems.count
+    func numberOfSections(in _: UITableView) -> Int {
+        return menuSections.count
+    }
+
+    func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int {
+        return menuSections[section].count
     }
 
     func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
         let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
-        let item = menuItems[indexPath.row]
+        let item = menuSections[indexPath.section][indexPath.row]
 
         var config = cell.defaultContentConfiguration()
         config.text = item.title
         config.image = UIImage(systemName: item.icon)
+
+        if let subtitle = item.subtitle {
+            config.secondaryText = subtitle
+            config.secondaryTextProperties.color = .orange
+            config.secondaryTextProperties.font = .preferredFont(forTextStyle: .caption1)
+        }
+
         cell.contentConfiguration = config
         cell.accessoryType = .disclosureIndicator
 
@@ -232,6 +407,6 @@ extension MoreMenuViewController: UITableViewDataSource, UITableViewDelegate {
 
     func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
         tableView.deselectRow(at: indexPath, animated: true)
-        menuItems[indexPath.row].action()
+        menuSections[indexPath.section][indexPath.row].action()
     }
 }