ソースを参照

Merge branch 'dev'

Jon Mårtensson 3 年 前
コミット
0cfaad77ba

+ 1 - 1
Config.xcconfig

@@ -1,5 +1,5 @@
 APP_DISPLAY_NAME = iAPS
-APP_VERSION = 2.0.0
+APP_VERSION = 2.1.0
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 DEVELOPER_TEAM = ##TEAM_ID##

+ 1 - 0
Core_Data.xcdatamodeld/Core_Data.xcdatamodel/contents

@@ -36,6 +36,7 @@
     <entity name="LoopStatRecord" representedClassName="LoopStatRecord" syncable="YES" codeGenerationType="class">
         <attribute name="duration" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
         <attribute name="end" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
+        <attribute name="interval" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
         <attribute name="loopStatus" optional="YES" attributeType="String"/>
         <attribute name="start" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
     </entity>

+ 1 - 1
Dependencies/OmniBLE/Localizations/de.lproj/Localizable.strings

@@ -571,7 +571,7 @@
 "Critical Alerts" = "Kritische Warnungen";
 
 /* Description text for critical alerts */
-"The reminders above will not sound if your device is in Silent or Do Not Disturb mode.\n\nThere are other critical Pod alerts and alarms that will sound even if you device is set to Silent or Do Not Disturb mode." = "Die obigen Hinweise ertönen nicht, wenn sich dein Gerät im Modus Lautlos oder Nicht stören befindet.\n\n Es gibt weitere kritische Pod-Warnungen und Alarme, die auch dann ertönen, wenn dein Gerät auf \"Lautlos\" oder \"Nicht stören\" eingestellt ist.";
+"The reminders above will not sound if your device is in Silent or Do Not Disturb mode.\n\nThere are other critical Pod alerts and alarms that will sound even if your device is set to Silent or Do Not Disturb mode." = "Die obigen Hinweise ertönen nicht, wenn sich dein Gerät im Modus Lautlos oder Nicht stören befindet.\n\n Es gibt weitere kritische Pod-Warnungen und Alarme, die auch dann ertönen, wenn dein Gerät auf \"Lautlos\" oder \"Nicht stören\" eingestellt ist.";
 /* navigation title for notification settings */
 "Notification Settings" = "Benachrichtigungseinstellungen";
 

+ 1 - 1
Dependencies/OmniBLE/Localizations/nb.lproj/Localizable.strings

@@ -571,7 +571,7 @@
 "Critical Alerts" = "Kritiske varslinger";
 
 /* Description text for critical alerts */
-"The reminders above will not sound if your device is in Silent or Do Not Disturb mode.\n\nThere are other critical Pod alerts and alarms that will sound even if you device is set to Silent or Do Not Disturb mode." = "Påminnelsene over vil ikke lyde hvis enheten er i stillemodus eller Ikke forstyrr-modus.\n\nDet finnes andre kritiske pod-varsler og -alarmer som vil lyde selv om enheten er satt til stillemodus eller Ikke forstyrr.";
+"The reminders above will not sound if your device is in Silent or Do Not Disturb mode.\n\nThere are other critical Pod alerts and alarms that will sound even if your device is set to Silent or Do Not Disturb mode." = "Påminnelsene over vil ikke lyde hvis enheten er i stillemodus eller Ikke forstyrr-modus.\n\nDet finnes andre kritiske pod-varsler og -alarmer som vil lyde selv om enheten er satt til stillemodus eller Ikke forstyrr.";
 /* navigation title for notification settings */
 "Notification Settings" = "Varslingsinnstillinger";
 

+ 1 - 1
Dependencies/OmniBLE/Localizations/sv.lproj/Localizable.strings

@@ -571,7 +571,7 @@
 "Critical Alerts" = "Varningar";
 
 /* Description text for critical alerts */
-"The reminders above will not sound if your device is in Silent or Do Not Disturb mode.\n\nThere are other critical Pod alerts and alarms that will sound even if you device is set to Silent or Do Not Disturb mode." = "Påminnelser kommer vara tysta ifall att du har tyst läge eller stör ej - läge på. \n\nDet finns andra varningar som kommer att ljuda även vid tyst läge leer stör ej - läge.";
+"The reminders above will not sound if your device is in Silent or Do Not Disturb mode.\n\nThere are other critical Pod alerts and alarms that will sound even if your device is set to Silent or Do Not Disturb mode." = "Påminnelser kommer vara tysta ifall att du har tyst läge eller stör ej - läge på. \n\nDet finns andra varningar som kommer att ljuda även vid tyst läge leer stör ej - läge.";
 /* navigation title for notification settings */
 "Notification Settings" = "Notisinställningar";
 

+ 12 - 0
FreeAPS.xcodeproj/project.pbxproj

@@ -26,6 +26,9 @@
 		19795118275953E50044850D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 198377D4266BFFF6004DE65E /* Localizable.strings */; };
 		198377D2266BFFF6004DE65E /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 198377D4266BFFF6004DE65E /* Localizable.strings */; };
 		199561C1275E61A50077B976 /* HealthKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 199561C0275E61A50077B976 /* HealthKit.framework */; };
+		19A910302A24BF6300C8951B /* StatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19A9102F2A24BF6300C8951B /* StatsView.swift */; };
+		19A910362A24D6D700C8951B /* DateFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19A910352A24D6D700C8951B /* DateFilter.swift */; };
+		19A910382A24EF3200C8951B /* ChartsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19A910372A24EF3200C8951B /* ChartsView.swift */; };
 		19B0EF2128F6D66200069496 /* Statistics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19B0EF2028F6D66200069496 /* Statistics.swift */; };
 		19D466A329AA2B80004D5F33 /* FPUConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19D466A229AA2B80004D5F33 /* FPUConfigDataFlow.swift */; };
 		19D466A529AA2BD4004D5F33 /* FPUConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19D466A429AA2BD4004D5F33 /* FPUConfigProvider.swift */; };
@@ -532,6 +535,9 @@
 		199561C0275E61A50077B976 /* HealthKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = HealthKit.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS8.0.sdk/System/Library/Frameworks/HealthKit.framework; sourceTree = DEVELOPER_DIR; };
 		199732B4271B72DD00129A3F /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Localizable.strings"; sourceTree = "<group>"; };
 		199732B5271B9EE900129A3F /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
+		19A9102F2A24BF6300C8951B /* StatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsView.swift; sourceTree = "<group>"; };
+		19A910352A24D6D700C8951B /* DateFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFilter.swift; sourceTree = "<group>"; };
+		19A910372A24EF3200C8951B /* ChartsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartsView.swift; sourceTree = "<group>"; };
 		19B0EF2028F6D66200069496 /* Statistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Statistics.swift; sourceTree = "<group>"; };
 		19C166682756EFBD00ED12E3 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/InfoPlist.strings; sourceTree = "<group>"; };
 		19C166692756EFBD00ED12E3 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
@@ -1108,6 +1114,8 @@
 			isa = PBXGroup;
 			children = (
 				19F95FF929F1102A00314DDC /* StatRootView.swift */,
+				19A9102F2A24BF6300C8951B /* StatsView.swift */,
+				19A910372A24EF3200C8951B /* ChartsView.swift */,
 			);
 			path = View;
 			sourceTree = "<group>";
@@ -1591,6 +1599,7 @@
 				FE41E4D529463EE20047FD55 /* NightscoutPreferences.swift */,
 				1967DFBD29D052C200759F30 /* Icons.swift */,
 				19D4E4EA29FC6A9F00351451 /* TIRforChart.swift */,
+				19A910352A24D6D700C8951B /* DateFilter.swift */,
 			);
 			path = Models;
 			sourceTree = "<group>";
@@ -2552,6 +2561,7 @@
 				3811DF0225CA9FEA00A708ED /* Credentials.swift in Sources */,
 				19DC678529CA67A400FD9EC4 /* OverrideProfilesRootView.swift in Sources */,
 				389A572026079BAA00BC102F /* Interpolation.swift in Sources */,
+				19A910382A24EF3200C8951B /* ChartsView.swift in Sources */,
 				38B4F3C625E5017E00E76A18 /* NotificationCenter.swift in Sources */,
 				19D466A729AA2C22004D5F33 /* FPUConfigStateModel.swift in Sources */,
 				38E44528274E401C00EC9A94 /* Protected.swift in Sources */,
@@ -2593,6 +2603,7 @@
 				38EA05DA261F6E7C0064E39B /* SimpleLogReporter.swift in Sources */,
 				3811DE6125C9D4D500A708ED /* ViewModifiers.swift in Sources */,
 				3811DEAC25C9D88300A708ED /* NightscoutManager.swift in Sources */,
+				19A910302A24BF6300C8951B /* StatsView.swift in Sources */,
 				CEB434E528B8FF5D00B70274 /* UIColor.swift in Sources */,
 				190EBCCB29FF13CB00BA767D /* StatConfigRootView.swift in Sources */,
 				3811DEA925C9D88300A708ED /* AppearanceManager.swift in Sources */,
@@ -2656,6 +2667,7 @@
 				38E44536274E411700EC9A94 /* Disk.swift in Sources */,
 				2BE9A6FA20875F6F4F9CD461 /* PumpSettingsEditorProvider.swift in Sources */,
 				6B9625766B697D1C98E455A2 /* PumpSettingsEditorStateModel.swift in Sources */,
+				19A910362A24D6D700C8951B /* DateFilter.swift in Sources */,
 				A0B8EC8CC5CD1DD237D1BCD2 /* PumpSettingsEditorRootView.swift in Sources */,
 				E06B911A275B5EEA003C04B6 /* Array+Extension.swift in Sources */,
 				38EA0600262091870064E39B /* BolusProgressViewStyle.swift in Sources */,

+ 50 - 91
FreeAPS/Sources/APS/APSManager.swift

@@ -205,9 +205,26 @@ final class BaseAPSManager: APSManager, Injectable {
         debug(.apsManager, "Starting loop with a delay of \(UIApplication.shared.backgroundTimeRemaining.rounded())")
 
         lastStartLoopDate = Date()
+
+        var previousLoop = [LoopStatRecord]()
+        var interval: Double?
+
+        coredataContext.performAndWait {
+            let requestStats = LoopStatRecord.fetchRequest() as NSFetchRequest<LoopStatRecord>
+            let sortStats = NSSortDescriptor(key: "end", ascending: false)
+            requestStats.sortDescriptors = [sortStats]
+            requestStats.fetchLimit = 1
+            try? previousLoop = coredataContext.fetch(requestStats)
+
+            if (previousLoop.first?.end ?? .distantFuture) < lastStartLoopDate {
+                interval = roundDouble((lastStartLoopDate - (previousLoop.first?.end ?? Date())).timeInterval / 60, 1)
+            }
+        }
+
         var loopStatRecord = LoopStats(
             start: lastStartLoopDate,
-            loopStatus: "Starting"
+            loopStatus: "Starting",
+            interval: interval
         )
 
         isLooping.send(true)
@@ -810,96 +827,36 @@ final class BaseAPSManager: APSManager, Injectable {
                 }
 
                 var lsr = [LoopStatRecord]()
-                var successRate: Double?
-                var successNR = 0
-                var errorNR = 0
-                var minimumInt = 999.0
-                var maximumInt = 0.0
-                var minimumLoopTime = 9999.0
-                var maximumLoopTime = 0.0
-                var timeIntervalLoops = 0.0
-                var previousTimeLoop = Date()
-                var timeForOneLoop = 0.0
-                var averageLoopTime = 0.0
-                var timeForOneLoopArray: [Double] = []
-                var medianLoopTime = 0.0
-                var timeIntervalLoopArray: [Double] = []
-                var medianInterval = 0.0
-                var averageIntervalLoops = 0.0
-                var averageLoopDuration = 0.0
 
                 let requestLSR = LoopStatRecord.fetchRequest() as NSFetchRequest<LoopStatRecord>
                 requestLSR.predicate = NSPredicate(
-                    format: "start > %@",
+                    format: "interval > 0 AND start > %@",
                     Date().addingTimeInterval(-24.hours.timeInterval) as NSDate
                 )
                 let sortLSR = NSSortDescriptor(key: "start", ascending: false)
                 requestLSR.sortDescriptors = [sortLSR]
 
                 try? lsr = coredataContext.fetch(requestLSR)
-
-                if lsr.isNotEmpty {
-                    var i = 0.0
-                    if let loopEnd = lsr[0].end {
-                        previousTimeLoop = loopEnd
-                    }
-                    for each in lsr {
-                        if let loopEnd = each.end {
-                            let loopDuration = each.duration
-
-                            if each.loopStatus!.contains("Success") {
-                                successNR += 1
-                            } else {
-                                errorNR += 1
-                            }
-
-                            i += 1
-                            timeIntervalLoops = (previousTimeLoop - (each.start ?? previousTimeLoop)).timeInterval / 60
-
-                            if timeIntervalLoops > 0.0, i != 1 {
-                                timeIntervalLoopArray.append(timeIntervalLoops)
-                            }
-                            if timeIntervalLoops > maximumInt {
-                                maximumInt = timeIntervalLoops
-                            }
-                            if timeIntervalLoops < minimumInt, i != 1 {
-                                minimumInt = timeIntervalLoops
-                            }
-                            timeForOneLoop = loopDuration
-                            timeForOneLoopArray.append(timeForOneLoop)
-
-                            if timeForOneLoop >= maximumLoopTime, timeForOneLoop != 0.0 {
-                                maximumLoopTime = timeForOneLoop
-                            }
-                            if timeForOneLoop <= minimumLoopTime, timeForOneLoop != 0.0 {
-                                minimumLoopTime = timeForOneLoop
-                            }
-                            previousTimeLoop = loopEnd
-                        }
-                    }
-                    successRate = (Double(successNR) / Double(i)) * 100
-
-                    // Average Loop Interval in minutes
-                    let timeOfFirstIndex = lsr[0].start ?? Date()
-                    let lastIndexWithTimestamp = lsr.count - 1
-                    let timeOfLastIndex = lsr[lastIndexWithTimestamp].end ?? Date()
-                    averageLoopTime = (timeOfFirstIndex - timeOfLastIndex).timeInterval / 60 / Double(errorNR + successNR)
-
-                    // Median values
-                    medianLoopTime = medianCalculation(array: timeForOneLoopArray)
-                    medianInterval = medianCalculation(array: timeIntervalLoopArray)
-                    // Average time interval between loops
-                    averageIntervalLoops = timeIntervalLoopArray.reduce(0, +) / Double(timeIntervalLoopArray.count)
-                    // Average loop duration
-                    averageLoopDuration = timeForOneLoopArray.reduce(0, +) / Double(timeForOneLoopArray.count)
-                }
-
-                if minimumInt == 999.0 {
-                    minimumInt = 0.0
-                }
-                if minimumLoopTime == 9999.0 {
-                    minimumLoopTime = 0.0
-                }
+                let loops = lsr
+
+                let durationArray = loops.compactMap({ each in each.duration })
+                let durationArrayCount = durationArray.count
+                let successsNR = loops.compactMap({ each in each.loopStatus }).filter({ each in each!.contains("Success") }).count
+
+                let durationAverage = durationArray.reduce(0, +) / Double(durationArrayCount)
+                let medianDuration = medianCalculation(array: durationArray)
+                let minimumDuration = durationArray.min() ?? 0
+                let maximumDuration = durationArray.max() ?? 0
+                let errorNR = durationArrayCount - successsNR
+                let successRate: Double? = (Double(successsNR) / Double(successsNR + errorNR)) * 100
+                let loopNr = successsNR + errorNR
+
+                let intervalArray = loops.compactMap({ each in each.interval })
+                let intervalArrayCount = intervalArray.count
+                let intervalAverage = intervalArray.reduce(0, +) / Double(intervalArrayCount)
+                let intervalMedian = medianCalculation(array: intervalArray)
+                let maximumInterval = intervalArray.max() ?? 0
+                let minimumInterval = intervalArray.min() ?? 0
 
                 var glucose = [Readings]()
 
@@ -1119,18 +1076,18 @@ final class BaseAPSManager: APSManager, Injectable {
                 let nrOfCGMReadings = nr1
 
                 let loopstat = LoopCycles(
-                    loops: successNR + errorNR,
+                    loops: loopNr,
                     errors: errorNR,
                     readings: Int(nrOfCGMReadings),
                     success_rate: Decimal(round(successRate ?? 0)),
-                    avg_interval: roundDecimal(Decimal(averageLoopTime), 1),
-                    median_interval: roundDecimal(Decimal(medianInterval), 1),
-                    min_interval: roundDecimal(Decimal(minimumInt), 1),
-                    max_interval: roundDecimal(Decimal(maximumInt), 1),
-                    avg_duration: Decimal(roundDouble(averageLoopDuration, 2)),
-                    median_duration: Decimal(roundDouble(medianLoopTime, 2)),
-                    min_duration: roundDecimal(Decimal(minimumLoopTime), 2),
-                    max_duration: Decimal(roundDouble(maximumLoopTime, 1))
+                    avg_interval: roundDecimal(Decimal(intervalAverage), 1),
+                    median_interval: roundDecimal(Decimal(intervalMedian), 1),
+                    min_interval: roundDecimal(Decimal(minimumInterval), 1),
+                    max_interval: roundDecimal(Decimal(maximumInterval), 1),
+                    avg_duration: Decimal(roundDouble(durationAverage, 2)),
+                    median_duration: Decimal(roundDouble(medianDuration, 2)),
+                    min_duration: roundDecimal(Decimal(minimumDuration), 2),
+                    max_duration: Decimal(roundDouble(maximumDuration, 1))
                 )
 
                 // TIR calcs for every case
@@ -1340,9 +1297,11 @@ final class BaseAPSManager: APSManager, Injectable {
             nLS.end = loopStatRecord.end ?? Date()
             nLS.loopStatus = loopStatRecord.loopStatus
             nLS.duration = loopStatRecord.duration ?? 0.0
+            nLS.interval = loopStatRecord.interval ?? 0.0
 
             try? self.coredataContext.save()
         }
+        print("LoopStatRecords: \(loopStatRecord)")
         print("Test time of LoopStats computation: \(-1 * LoopStatsStartedAt.timeIntervalSinceNow) s")
     }
 

+ 1 - 1
FreeAPS/Sources/Localizations/Main/en.lproj/Localizable.strings

@@ -1232,7 +1232,7 @@ Enact a temp Basal or a temp target */
 "Start Profile" = "Start Profile";
 
 /* */
-"Your profile basal insulin will be adjusted with the override percentage and your profile ISF and CR will be inversly adjusted with the percentage.\n\nIf you toggle off the override every profile setting will return to normal." = "Your profile basal insulin will be adjusted with the override percentage and your profile ISF and CR will be inversly adjusted with the percentage.";
+"Your profile basal insulin will be adjusted with the override percentage and your profile ISF and CR will be inversly adjusted with the percentage." = "Your profile basal insulin will be adjusted with the override percentage and your profile ISF and CR will be inversly adjusted with the percentage.";
 
 /* */
 "Starting this override will change your Profiles and/or your Target Glucose used for looping during the entire selected duration. Tapping ”Start Profile” will start your new profile or edit your current active profile." = "Starting this override will change your Profiles and/or your Target Glucose used for looping during the entire selected duration. Tapping ”Start Profile” will start your new profile or edit your current active profile.";

+ 1 - 1
FreeAPS/Sources/Localizations/Main/sv.lproj/Localizable.strings

@@ -1231,7 +1231,7 @@ Enact a temp Basal or a temp target */
 "Start Profile" = "Starta profil";
 
 /* */
-"Your profile basal insulin will be adjusted with the override percentage and your profile ISF and CR will be inversly adjusted with the percentage.\n\nIf you toggle off the override every profile setting will return to normal." = "Din vanliga basal kommer att justeras procentuellt enligt ovan, medan din normala korrektionsfaktor och insulinkvot CR kommer att bli omvänt justerade.";
+"Your profile basal insulin will be adjusted with the override percentage and your profile ISF and CR will be inversly adjusted with the percentage." = "Din vanliga basal kommer att justeras procentuellt enligt ovan, medan din normala korrektionsfaktor och insulinkvot CR kommer att bli omvänt justerade.";
 
 /* */
 "Starting this override will change your Profiles and/or your Target Glucose used for looping during the entire selected duration. Tapping ”Start Profile” will start your new profile or edit your current active profile." = "Om du klickar 'Starta profil' kommer detta att tillfälligt att ändra dina normala inställningar för under hela perioden som du valt";

+ 10 - 0
FreeAPS/Sources/Models/DateFilter.swift

@@ -0,0 +1,10 @@
+
+import Foundation
+
+struct DateFilter {
+    var today = Calendar.current.startOfDay(for: Date()) as NSDate
+    var day = Date().addingTimeInterval(-24.hours.timeInterval) as NSDate
+    var week = Date().addingTimeInterval(-7.days.timeInterval) as NSDate
+    var month = Date().addingTimeInterval(-30.days.timeInterval) as NSDate
+    var total = Date().addingTimeInterval(-90.days.timeInterval) as NSDate
+}

+ 5 - 1
FreeAPS/Sources/Models/LoopStats.swift

@@ -5,13 +5,16 @@ struct LoopStats: JSON, Equatable {
     var end: Date?
     var duration: Double?
     var loopStatus: String
+    var interval: Double?
 
     init(
         start: Date,
-        loopStatus: String
+        loopStatus: String,
+        interval: Double?
     ) {
         self.start = start
         self.loopStatus = loopStatus
+        self.interval = interval
     }
 }
 
@@ -21,5 +24,6 @@ extension LoopStats {
         case end
         case duration
         case loopStatus
+        case interval
     }
 }

+ 5 - 6
FreeAPS/Sources/Modules/Stat/StatStateModel.swift

@@ -5,12 +5,11 @@ import Swinject
 extension Stat {
     final class StateModel: BaseStateModel<Provider> {
         @Injected() var settings: SettingsManager!
-        @Published var highLimit: Decimal?
-        @Published var lowLimit: Decimal?
-        @Published var overrideUnit: Bool?
-        @Published var layingChart: Bool?
-
-        private(set) var units: GlucoseUnits = .mmolL
+        @Published var highLimit: Decimal = 10 / 0.0555
+        @Published var lowLimit: Decimal = 4 / 0.0555
+        @Published var overrideUnit: Bool = false
+        @Published var layingChart: Bool = false
+        @Published var units: GlucoseUnits = .mmolL
 
         override func subscribe() {
             highLimit = settingsManager.settings.high

+ 225 - 0
FreeAPS/Sources/Modules/Stat/View/ChartsView.swift

@@ -0,0 +1,225 @@
+//
+//  FilteredLoopsView.swift
+//  FreeAPS
+//
+//  Created by Jon Mårtensson on 2023-05-29.
+//
+import Charts
+import CoreData
+import SwiftDate
+import SwiftUI
+
+struct ChartsView: View {
+    @FetchRequest var fetchRequest: FetchedResults<Readings>
+
+    @Binding var highLimit: Decimal
+    @Binding var lowLimit: Decimal
+    @Binding var units: GlucoseUnits
+    @Binding var overrideUnit: Bool
+    @Binding var standing: Bool
+
+    @State var headline: Color = .secondary
+
+    private let conversionFactor = 0.0555
+
+    var body: some View {
+        glucoseChart
+        Rectangle().fill(.cyan.opacity(0.2)).frame(maxHeight: 3)
+        if standing { tirChart } else { standingTIRchart }
+    }
+
+    init(
+        filter: NSDate,
+        _ highLimit: Binding<Decimal>,
+        _ lowLimit: Binding<Decimal>,
+        _ units: Binding<GlucoseUnits>,
+        _ overrideUnit: Binding<Bool>,
+        _ standing: Binding<Bool>
+    ) { _fetchRequest = FetchRequest<Readings>(
+        sortDescriptors: [NSSortDescriptor(key: "date", ascending: false)],
+        predicate: NSPredicate(format: "glucose > 0 AND date > %@", filter)
+    )
+    _highLimit = highLimit
+    _lowLimit = lowLimit
+    _units = units
+    _overrideUnit = overrideUnit
+    _standing = standing
+    }
+
+    var glucoseChart: some View {
+        // Be aware of the low/lowLimit difference. lowLimit/highLimit is always in mg/dl, whereas low/high is configurable in settings
+        let low = lowLimit * (units == .mmolL ? Decimal(conversionFactor) : 1)
+        let high = highLimit * (units == .mmolL ? Decimal(conversionFactor) : 1)
+        let readings = fetchRequest
+        let count = readings.count
+        // The symbol size when fewer readings are larger
+        let sizeOfDataPoints: CGFloat = count < 20 ? 50 : count < 50 ? 35 : count > 2000 ? 5 : 15
+
+        return Chart {
+            ForEach(readings.filter({ $0.glucose > Int(highLimit) }), id: \.date) { item in
+                PointMark(
+                    x: .value("Date", item.date ?? Date()),
+                    y: .value("High", Double(item.glucose) * (units == .mmolL ? self.conversionFactor : 1))
+                )
+                .foregroundStyle(.orange)
+                .symbolSize(sizeOfDataPoints)
+            }
+            ForEach(
+                readings
+                    .filter({
+                        $0.glucose >= Int(lowLimit) && $0
+                            .glucose <= Int(highLimit) }),
+                id: \.date
+            ) { item in
+                PointMark(
+                    x: .value("Date", item.date ?? Date()),
+                    y: .value("In Range", Double(item.glucose) * (units == .mmolL ? conversionFactor : 1))
+                )
+                .foregroundStyle(.green)
+                .symbolSize(sizeOfDataPoints)
+            }
+            ForEach(readings.filter({ $0.glucose < Int(lowLimit) }), id: \.date) { item in
+                PointMark(
+                    x: .value("Date", item.date ?? Date()),
+                    y: .value("Low", Double(item.glucose) * (units == .mmolL ? conversionFactor : 1))
+                )
+                .foregroundStyle(.red)
+                .symbolSize(sizeOfDataPoints)
+            }
+        }
+        .chartYAxis {
+            AxisMarks(
+                values: [
+                    0,
+                    low,
+                    high,
+                    units == .mmolL ? 15 : 270
+                ]
+            )
+        } // .background(.gray.opacity(0.05))
+    }
+
+    var tirChart: some View {
+        let fetched = tir()
+
+        let low = lowLimit * (units == .mmolL ? Decimal(conversionFactor) : 1)
+        let high = highLimit * (units == .mmolL ? Decimal(conversionFactor) : 1)
+
+        let data: [ShapeModel] = [
+            .init(
+                type: NSLocalizedString(
+                    "Low",
+                    comment: ""
+                ) + " (\(low.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))",
+                percent: fetched[0].decimal
+            ),
+            .init(type: NSLocalizedString("In Range", comment: ""), percent: fetched[1].decimal),
+            .init(
+                type: NSLocalizedString(
+                    "High",
+                    comment: ""
+                ) + " (\(high.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))",
+                percent: fetched[2].decimal
+            )
+        ]
+        return Chart(data) { shape in
+            BarMark(
+                x: .value("TIR", shape.percent)
+            )
+            .foregroundStyle(by: .value("Group", shape.type))
+            .annotation(position: .top, alignment: .center) {
+                Text(
+                    "\(shape.percent, format: .number.precision(.fractionLength(0))) %"
+                ).font(.footnote).foregroundColor(.secondary)
+            }
+        }
+        .chartXAxis(.hidden)
+        .chartForegroundStyleScale([
+            NSLocalizedString(
+                "Low",
+                comment: ""
+            ) + " (\(low.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))": .red,
+            NSLocalizedString("In Range", comment: ""): .green,
+            NSLocalizedString(
+                "High",
+                comment: ""
+            ) + " (\(high.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))": .orange
+        ]).frame(maxHeight: 25)
+    }
+
+    var standingTIRchart: some View {
+        let fetched = tir()
+        let low = lowLimit * (units == .mmolL ? Decimal(conversionFactor) : 1)
+        let high = highLimit * (units == .mmolL ? Decimal(conversionFactor) : 1)
+        let data: [ShapeModel] = [
+            .init(
+                type: NSLocalizedString(
+                    "Low",
+                    comment: ""
+                ) + " (\(low.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))",
+                percent: fetched[0].decimal
+            ),
+            .init(type: NSLocalizedString("In Range", comment: ""), percent: fetched[1].decimal),
+            .init(
+                type: NSLocalizedString(
+                    "High",
+                    comment: ""
+                ) + " (\(high.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))",
+                percent: fetched[2].decimal
+            )
+        ]
+
+        return VStack(alignment: .center) {
+            Chart(data) { shape in
+                BarMark(
+                    x: .value("Shape", shape.type),
+                    y: .value("Percentage", shape.percent)
+                )
+                .foregroundStyle(by: .value("Group", shape.type))
+                .annotation(position: shape.percent <= 9 ? .top : .overlay, alignment: .center) {
+                    Text(shape.percent == 0 ? "" : "\(shape.percent, format: .number.precision(.fractionLength(0))) %")
+                }
+            }
+            .chartYAxis(.hidden)
+            .chartLegend(.hidden)
+            .chartForegroundStyleScale([
+                NSLocalizedString(
+                    "Low",
+                    comment: ""
+                ) + " (\(low.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))": .red,
+                NSLocalizedString("In Range", comment: ""): .green,
+                NSLocalizedString(
+                    "High",
+                    comment: ""
+                ) + " (\(high.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))": .orange
+            ])
+        }
+    }
+
+    private func tir() -> [(decimal: Decimal, string: String)] {
+        let hypoLimit = Int(lowLimit)
+        let hyperLimit = Int(highLimit)
+
+        let glucose = fetchRequest
+
+        let justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
+        let totalReadings = justGlucoseArray.count
+
+        let hyperArray = glucose.filter({ $0.glucose >= hyperLimit })
+        let hyperReadings = hyperArray.compactMap({ each in each.glucose as Int16 }).count
+        let hyperPercentage = Double(hyperReadings) / Double(totalReadings) * 100
+
+        let hypoArray = glucose.filter({ $0.glucose <= hypoLimit })
+        let hypoReadings = hypoArray.compactMap({ each in each.glucose as Int16 }).count
+        let hypoPercentage = Double(hypoReadings) / Double(totalReadings) * 100
+
+        let tir = 100 - (hypoPercentage + hyperPercentage)
+
+        var array: [(decimal: Decimal, string: String)] = []
+        array.append((decimal: Decimal(hypoPercentage), string: "Low"))
+        array.append((decimal: Decimal(tir), string: "NormaL"))
+        array.append((decimal: Decimal(hyperPercentage), string: "High"))
+
+        return array
+    }
+}

+ 93 - 738
FreeAPS/Sources/Modules/Stat/View/StatRootView.swift

@@ -10,54 +10,11 @@ extension Stat {
         @StateObject var state = StateModel()
 
         @FetchRequest(
-            entity: Readings.entity(),
-            sortDescriptors: [NSSortDescriptor(key: "date", ascending: false)], predicate: NSPredicate(
-                format: "date >= %@", Calendar.current.startOfDay(for: Date()) as NSDate
-            )
-        ) var fetchedGlucoseDay: FetchedResults<Readings>
-
-        @FetchRequest(
-            entity: Readings.entity(),
-            sortDescriptors: [NSSortDescriptor(key: "date", ascending: false)],
-            predicate: NSPredicate(format: "date > %@", Date().addingTimeInterval(-24.hours.timeInterval) as NSDate)
-        ) var fetchedGlucoseTwentyFourHours: FetchedResults<Readings>
-
-        @FetchRequest(
-            entity: Readings.entity(),
-            sortDescriptors: [NSSortDescriptor(key: "date", ascending: false)],
-            predicate: NSPredicate(format: "date > %@", Date().addingTimeInterval(-7.days.timeInterval) as NSDate)
-        ) var fetchedGlucoseWeek: FetchedResults<Readings>
-
-        @FetchRequest(
-            entity: Readings.entity(),
-            sortDescriptors: [NSSortDescriptor(key: "date", ascending: false)], predicate: NSPredicate(
-                format: "date > %@",
-                Date().addingTimeInterval(-30.days.timeInterval) as NSDate
-            )
-        ) var fetchedGlucoseMonth: FetchedResults<Readings>
-
-        @FetchRequest(
-            entity: Readings.entity(),
-            sortDescriptors: [NSSortDescriptor(key: "date", ascending: false)], predicate: NSPredicate(
-                format: "date > %@",
-                Date().addingTimeInterval(-90.days.timeInterval) as NSDate
-            )
-        ) var fetchedGlucose: FetchedResults<Readings>
-
-        @FetchRequest(
             entity: TDD.entity(),
             sortDescriptors: [NSSortDescriptor(key: "timestamp", ascending: false)]
         ) var fetchedTDD: FetchedResults<TDD>
 
         @FetchRequest(
-            entity: LoopStatRecord.entity(),
-            sortDescriptors: [NSSortDescriptor(key: "start", ascending: false)], predicate: NSPredicate(
-                format: "start > %@",
-                Date().addingTimeInterval(-24.hours.timeInterval) as NSDate
-            )
-        ) var fetchedLoopStats: FetchedResults<LoopStatRecord>
-
-        @FetchRequest(
             entity: InsulinDistribution.entity(),
             sortDescriptors: [NSSortDescriptor(key: "date", ascending: false)]
         ) var fetchedInsulin: FetchedResults<InsulinDistribution>
@@ -79,722 +36,120 @@ extension Stat {
         @State var conversionFactor = 0.0555
 
         @ViewBuilder func stats() -> some View {
-            if state.layingChart ?? true {
-                bloodGlucose
-                Divider()
-            } else {
-                bloodGlucose
-                Divider()
-                standingTIRchart
-                Divider()
+            ZStack {
+                Color.gray.opacity(0.05).ignoresSafeArea(.all)
+                let filter = DateFilter()
+                switch selectedDuration {
+                case .Today:
+                    StatsView(
+                        filter: filter.today,
+                        $state.highLimit,
+                        $state.lowLimit,
+                        $state.units,
+                        $state.overrideUnit
+                    )
+                case .Day:
+                    StatsView(
+                        filter: filter.day,
+                        $state.highLimit,
+                        $state.lowLimit,
+                        $state.units,
+                        $state.overrideUnit
+                    )
+                case .Week:
+                    StatsView(
+                        filter: filter.week,
+                        $state.highLimit,
+                        $state.lowLimit,
+                        $state.units,
+                        $state.overrideUnit
+                    )
+                case .Month:
+                    StatsView(
+                        filter: filter.month,
+                        $state.highLimit,
+                        $state.lowLimit,
+                        $state.units,
+                        $state.overrideUnit
+                    )
+                case .Total:
+                    StatsView(
+                        filter: filter.total,
+                        $state.highLimit,
+                        $state.lowLimit,
+                        $state.units,
+                        $state.overrideUnit
+                    )
+                }
             }
-            loops
-            Divider()
-            hba1c
         }
 
         @ViewBuilder func chart() -> some View {
+            let filter = DateFilter()
             switch selectedDuration {
             case .Today:
-                glucoseChart
+                ChartsView(
+                    filter: filter.today,
+                    $state.highLimit,
+                    $state.lowLimit,
+                    $state.units,
+                    $state.overrideUnit,
+                    $state.layingChart
+                )
             case .Day:
-                glucoseChartTwentyFourHours
+                ChartsView(
+                    filter: filter.day,
+                    $state.highLimit,
+                    $state.lowLimit,
+                    $state.units,
+                    $state.overrideUnit,
+                    $state.layingChart
+                )
             case .Week:
-                glucoseChartWeek
+                ChartsView(
+                    filter: filter.week,
+                    $state.highLimit,
+                    $state.lowLimit,
+                    $state.units,
+                    $state.overrideUnit,
+                    $state.layingChart
+                )
             case .Month:
-                glucoseChartMonth
+                ChartsView(
+                    filter: filter.month,
+                    $state.highLimit,
+                    $state.lowLimit,
+                    $state.units,
+                    $state.overrideUnit,
+                    $state.layingChart
+                )
             case .Total:
-                glucoseChart90
-            }
-            if state.layingChart ?? true {
-                tirChart
+                ChartsView(
+                    filter: filter.total,
+                    $state.highLimit,
+                    $state.lowLimit,
+                    $state.units,
+                    $state.overrideUnit,
+                    $state.layingChart
+                )
             }
         }
 
         var body: some View {
-            ZStack {
-                VStack(alignment: .center) {
-                    chart().padding(.top, 20)
-                    Divider()
-                    stats()
-                    Divider()
-                    Picker("Duration", selection: $selectedDuration) {
-                        ForEach(Duration.allCases) { duration in
-                            Text(NSLocalizedString(duration.rawValue, comment: "")).tag(Optional(duration))
-                        }
+            VStack(alignment: .center) {
+                chart().padding(.top, 20)
+                Picker("Duration", selection: $selectedDuration) {
+                    ForEach(Duration.allCases) { duration in
+                        Text(NSLocalizedString(duration.rawValue, comment: "")).tag(Optional(duration))
                     }
-                    .pickerStyle(.segmented)
                 }
+                .pickerStyle(.segmented).background(.cyan.opacity(0.2))
+                stats()
             }
             .onAppear(perform: configureView)
             .navigationBarTitle("Statistics")
             .navigationBarTitleDisplayMode(.automatic)
             .navigationBarItems(leading: Button("Close", action: state.hideModal))
         }
-
-        var loops: some View {
-            VStack {
-                let loops_ = loopStats(fetchedLoopStats)
-                HStack {
-                    ForEach(0 ..< loops_.count, id: \.self) { index in
-                        VStack {
-                            Text(NSLocalizedString(loops_[index].string, comment: "")).font(.subheadline)
-                                .foregroundColor(.secondary)
-                            Text(
-                                index == 0 ? loops_[index].double.formatted() : (
-                                    index == 2 ? loops_[index].double
-                                        .formatted(.number.grouping(.never).rounded().precision(.fractionLength(2))) :
-                                        loops_[index]
-                                        .double
-                                        .formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))
-                                )
-                            )
-                        }.padding(.horizontal, 6)
-                    }
-                }
-            }
-        }
-
-        var hba1c: some View {
-            let useUnit: GlucoseUnits = (state.units == .mmolL && (state.overrideUnit ?? false)) ? .mgdL :
-                (state.units == .mgdL && (state.overrideUnit ?? false) || state.units == .mmolL) ? .mmolL : .mgdL
-            return HStack {
-                let hba1cs = glucoseStats(fetchedGlucose)
-                let hba1cString = (
-                    useUnit == .mmolL ? hba1cs.ifcc
-                        .formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) : hba1cs.ngsp
-                        .formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))
-                        + " %"
-                )
-
-                VStack {
-                    Text("HbA1C").font(.subheadline).foregroundColor(headline)
-                    HStack {
-                        VStack {
-                            Text(hba1cString)
-                        }
-                    }
-                }.padding([.horizontal], 15)
-                VStack {
-                    Text("SD").font(.subheadline).foregroundColor(.secondary)
-                    HStack {
-                        VStack {
-                            Text(
-                                hba1cs.sd
-                                    .formatted(
-                                        .number.grouping(.never).rounded()
-                                            .precision(.fractionLength(state.units == .mmolL ? 1 : 0))
-                                    )
-                            )
-                        }
-                    }
-                }.padding([.horizontal], 15)
-                VStack {
-                    Text("CV").font(.subheadline).foregroundColor(.secondary)
-                    HStack {
-                        VStack {
-                            Text(
-                                hba1cs.cv.formatted(.number.grouping(.never).rounded().precision(.fractionLength(0)))
-                            )
-                        }
-                    }
-                }.padding([.horizontal], 15)
-                // if selectedDuration == .Total || selectedDuration == .Today {
-                VStack {
-                    Text("Days").font(.subheadline).foregroundColor(.secondary)
-                    HStack {
-                        VStack {
-                            Text(numberOfDays.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))))
-                        }
-                    }
-                }.padding([.horizontal], 15)
-                // }
-            }
-        }
-
-        var bloodGlucose: some View {
-            VStack {
-                HStack {
-                    let bgs = glucoseStats(fetchedGlucose)
-                    VStack {
-                        HStack {
-                            Text(selectedDuration == .Today ? "Readings today" : "Readings / 24h").font(.subheadline)
-                                .foregroundColor(.secondary)
-                        }
-                        HStack {
-                            VStack {
-                                Text(
-                                    bgs.readings.formatted(.number.grouping(.never).rounded().precision(.fractionLength(0)))
-                                )
-                            }
-                        }
-                    }
-                    VStack {
-                        HStack {
-                            Text("Average").font(.subheadline).foregroundColor(headline)
-                        }
-                        HStack {
-                            VStack {
-                                Text(
-                                    bgs.average
-                                        .formatted(
-                                            .number.grouping(.never).rounded()
-                                                .precision(.fractionLength(state.units == .mmolL ? 1 : 0))
-                                        )
-                                )
-                            }
-                        }
-                    }
-                    VStack {
-                        HStack {
-                            Text("Median").font(.subheadline).foregroundColor(.secondary)
-                        }
-                        HStack {
-                            VStack {
-                                Text(
-                                    bgs.median
-                                        .formatted(
-                                            .number.grouping(.never).rounded()
-                                                .precision(.fractionLength(state.units == .mmolL ? 1 : 0))
-                                        )
-                                )
-                            }
-                        }
-                    }
-                }
-            }
-        }
-
-        var numberOfDays: Double {
-            let array = selectedDuration == .Today ? fetchedGlucoseDay : selectedDuration == .Day ?
-                fetchedGlucoseTwentyFourHours :
-                selectedDuration == .Week ? fetchedGlucoseWeek : selectedDuration == .Month ? fetchedGlucoseMonth :
-                selectedDuration ==
-                .Total ? fetchedGlucose : fetchedGlucoseDay
-
-            let endIndex = array.count - 1
-            var days = 0.0
-
-            if endIndex > 0 {
-                let firstElementTime = fetchedGlucose.first?.date ?? Date()
-                let lastElementTime = fetchedGlucose[endIndex].date ?? Date()
-                days = (firstElementTime - lastElementTime).timeInterval / 8.64E4
-            }
-            return days
-        }
-
-        var tirChart: some View {
-            let array = selectedDuration == .Today ? fetchedGlucoseDay : selectedDuration == .Day ?
-                fetchedGlucoseTwentyFourHours :
-                selectedDuration == .Week ? fetchedGlucoseWeek : selectedDuration == .Month ? fetchedGlucoseMonth :
-                selectedDuration ==
-                .Total ? fetchedGlucose : fetchedGlucoseDay
-            let fetched = tir(array)
-            let data: [ShapeModel] = [
-                .init(type: NSLocalizedString("Low", comment: ""), percent: fetched[0].decimal),
-                .init(type: NSLocalizedString("In Range", comment: ""), percent: fetched[1].decimal),
-                .init(type: NSLocalizedString("High", comment: ""), percent: fetched[2].decimal)
-            ]
-
-            return Chart(data) { shape in
-                BarMark(
-                    x: .value("TIR", shape.percent)
-                )
-                .foregroundStyle(by: .value("Group", shape.type))
-                .annotation(position: .overlay, alignment: .center) {
-                    Text(
-                        shape.percent == 0 ? "" : shape
-                            .percent < 12 ? "\(shape.percent, format: .number.precision(.fractionLength(0)))" :
-                            "\(shape.percent, format: .number.precision(.fractionLength(0))) %"
-                    )
-                }
-            }
-            .chartXAxis(.hidden)
-            .chartForegroundStyleScale([
-                NSLocalizedString("Low", comment: ""): .red,
-                NSLocalizedString("In Range", comment: ""): .green,
-                NSLocalizedString("High", comment: ""): .orange
-            ]).frame(maxHeight: 55)
-        }
-
-        var standingTIRchart: some View {
-            let array = selectedDuration == .Today ? fetchedGlucoseDay : selectedDuration == .Day ?
-                fetchedGlucoseTwentyFourHours :
-                selectedDuration == .Week ? fetchedGlucoseWeek : selectedDuration == .Month ? fetchedGlucoseMonth :
-                selectedDuration == .Total ? fetchedGlucose : fetchedGlucoseDay
-            let fetched = tir(array)
-            let data: [ShapeModel] = [
-                .init(type: NSLocalizedString("Low", comment: ""), percent: fetched[0].decimal),
-                .init(type: NSLocalizedString("In Range", comment: ""), percent: fetched[1].decimal),
-                .init(type: NSLocalizedString("High", comment: ""), percent: fetched[2].decimal)
-            ]
-
-            return VStack(alignment: .center) {
-                Chart(data) { shape in
-                    BarMark(
-                        x: .value("Shape", shape.type),
-                        y: .value("Percentage", shape.percent)
-                    )
-                    .foregroundStyle(by: .value("Group", shape.type))
-                    .annotation(position: shape.percent <= 9 ? .top : .overlay, alignment: .center) {
-                        Text(shape.percent == 0 ? "" : "\(shape.percent, format: .number.precision(.fractionLength(0))) %")
-                    }
-                }
-                .chartYAxis(.hidden)
-                .chartLegend(.hidden)
-                .chartForegroundStyleScale([
-                    NSLocalizedString("Low", comment: ""): .red,
-                    NSLocalizedString("In Range", comment: ""): .green,
-                    NSLocalizedString("High", comment: ""): .orange
-                ])
-            }
-        }
-
-        var glucoseChart: some View {
-            let count = fetchedGlucoseDay.count
-            let lowLimit = (state.lowLimit ?? (4 * 0.0555)) * (state.units == .mmolL ? Decimal(conversionFactor) : 1)
-            let highLimit = (state.highLimit ?? (10 * 0.0555)) * (state.units == .mmolL ? Decimal(conversionFactor) : 1)
-            return Chart {
-                ForEach(fetchedGlucoseDay.filter({ $0.glucose > Int(state.highLimit ?? 145) }), id: \.date) { item in
-                    PointMark(
-                        x: .value("Date", item.date ?? Date()),
-                        y: .value("High", Double(item.glucose) * (state.units == .mmolL ? conversionFactor : 1))
-                    )
-                    .foregroundStyle(.orange)
-                    .symbolSize(count < 20 ? 30 : 12)
-                }
-                ForEach(
-                    fetchedGlucoseDay
-                        .filter({ $0.glucose >= Int(state.lowLimit ?? 70) && $0.glucose <= Int(state.highLimit ?? 145) }),
-                    id: \.date
-                ) { item in
-                    PointMark(
-                        x: .value("Date", item.date ?? Date()),
-                        y: .value("In Range", Double(item.glucose) * (state.units == .mmolL ? conversionFactor : 1))
-                    )
-                    .foregroundStyle(.green)
-                    .symbolSize(count < 20 ? 30 : 12)
-                }
-                ForEach(fetchedGlucoseDay.filter({ $0.glucose < Int(state.lowLimit ?? 70) }), id: \.date) { item in
-                    PointMark(
-                        x: .value("Date", item.date ?? Date()),
-                        y: .value("Low", Double(item.glucose) * (state.units == .mmolL ? conversionFactor : 1))
-                    )
-                    .foregroundStyle(.red)
-                    .symbolSize(count < 20 ? 30 : 12)
-                }
-            }
-            .chartYAxis {
-                AxisMarks(
-                    values: [
-                        0,
-                        lowLimit,
-                        highLimit,
-                        state.units == .mmolL ? 15 : 270
-                    ]
-                )
-            }
-        }
-
-        var glucoseChartTwentyFourHours: some View {
-            let count = fetchedGlucoseTwentyFourHours.count
-            let lowLimit = (state.lowLimit ?? (4 * 0.0555)) * (state.units == .mmolL ? Decimal(conversionFactor) : 1)
-            let highLimit = (state.highLimit ?? (10 * 0.0555)) * (state.units == .mmolL ? Decimal(conversionFactor) : 1)
-            return Chart {
-                ForEach(fetchedGlucoseTwentyFourHours.filter({ $0.glucose > Int(state.highLimit ?? 145) }), id: \.date) { item in
-                    PointMark(
-                        x: .value("Date", item.date ?? Date()),
-                        y: .value("High", Double(item.glucose) * (state.units == .mmolL ? conversionFactor : 1))
-                    )
-                    .foregroundStyle(.orange)
-                    .symbolSize(count < 20 ? 20 : 10)
-                }
-                ForEach(
-                    fetchedGlucoseTwentyFourHours
-                        .filter({ $0.glucose >= Int(state.lowLimit ?? 70) && $0.glucose <= Int(state.highLimit ?? 145) }),
-                    id: \.date
-                ) { item in
-                    PointMark(
-                        x: .value("Date", item.date ?? Date()),
-                        y: .value("In Range", Double(item.glucose) * (state.units == .mmolL ? conversionFactor : 1))
-                    )
-                    .foregroundStyle(.green)
-                    .symbolSize(count < 20 ? 20 : 10)
-                }
-                ForEach(fetchedGlucoseTwentyFourHours.filter({ $0.glucose < Int(state.lowLimit ?? 70) }), id: \.date) { item in
-                    PointMark(
-                        x: .value("Date", item.date ?? Date()),
-                        y: .value("Low", Double(item.glucose) * (state.units == .mmolL ? conversionFactor : 1))
-                    )
-                    .foregroundStyle(.red)
-                    .symbolSize(count < 20 ? 20 : 10)
-                }
-            }
-            .chartYAxis {
-                AxisMarks(
-                    values: [
-                        0,
-                        lowLimit,
-                        highLimit,
-                        state.units == .mmolL ? 15 : 270
-                    ]
-                )
-            } }
-
-        var glucoseChartWeek: some View {
-            let lowLimit = (state.lowLimit ?? (4 * 0.0555)) * (state.units == .mmolL ? Decimal(conversionFactor) : 1)
-            let highLimit = (state.highLimit ?? (10 * 0.0555)) * (state.units == .mmolL ? Decimal(conversionFactor) : 1)
-            return Chart {
-                ForEach(fetchedGlucoseWeek.filter({ $0.glucose > Int(state.highLimit ?? 145) }), id: \.date) { item in
-                    PointMark(
-                        x: .value("Date", item.date ?? Date()),
-                        y: .value("Low", Double(item.glucose) * (state.units == .mmolL ? conversionFactor : 1))
-                    )
-                    .foregroundStyle(.orange)
-                    .symbolSize(5)
-                }
-                ForEach(
-                    fetchedGlucoseWeek
-                        .filter({ $0.glucose >= Int(state.lowLimit ?? 70) && $0.glucose <= Int(state.highLimit ?? 145) }),
-                    id: \.date
-                ) { item in
-                    PointMark(
-                        x: .value("Date", item.date ?? Date()),
-                        y: .value("In Range", Double(item.glucose) * (state.units == .mmolL ? conversionFactor : 1))
-                    )
-                    .foregroundStyle(.green)
-                    .symbolSize(5)
-                }
-                ForEach(fetchedGlucoseWeek.filter({ $0.glucose < Int(state.lowLimit ?? 70) }), id: \.date) { item in
-                    PointMark(
-                        x: .value("Date", item.date ?? Date()),
-                        y: .value("High", Double(item.glucose) * (state.units == .mmolL ? conversionFactor : 1))
-                    )
-                    .foregroundStyle(.red)
-                    .symbolSize(5)
-                }
-            }
-            .chartYAxis {
-                AxisMarks(
-                    values: [
-                        0,
-                        lowLimit,
-                        highLimit,
-                        state.units == .mmolL ? 15 : 270
-                    ]
-                )
-            }
-        }
-
-        var glucoseChartMonth: some View {
-            let lowLimit = (state.lowLimit ?? (4 * 0.0555)) * (state.units == .mmolL ? Decimal(conversionFactor) : 1)
-            let highLimit = (state.highLimit ?? (10 * 0.0555)) * (state.units == .mmolL ? Decimal(conversionFactor) : 1)
-            return Chart {
-                ForEach(fetchedGlucoseMonth.filter({ $0.glucose > Int(state.highLimit ?? 145) }), id: \.date) { item in
-                    PointMark(
-                        x: .value("Date", item.date ?? Date()),
-                        y: .value("Low", Double(item.glucose) * (state.units == .mmolL ? conversionFactor : 1))
-                    )
-                    .foregroundStyle(.orange)
-                    .symbolSize(2)
-                }
-                ForEach(
-                    fetchedGlucoseMonth
-                        .filter({ $0.glucose >= Int(state.lowLimit ?? 70) && $0.glucose <= Int(state.highLimit ?? 145) }),
-                    id: \.date
-                ) { item in
-                    PointMark(
-                        x: .value("Date", item.date ?? Date()),
-                        y: .value("In Range", Double(item.glucose) * (state.units == .mmolL ? conversionFactor : 1))
-                    )
-                    .foregroundStyle(.green)
-                    .symbolSize(2)
-                }
-                ForEach(fetchedGlucoseMonth.filter({ $0.glucose < Int(state.lowLimit ?? 70) }), id: \.date) { item in
-                    PointMark(
-                        x: .value("Date", item.date ?? Date()),
-                        y: .value("High", Double(item.glucose) * (state.units == .mmolL ? conversionFactor : 1))
-                    )
-                    .foregroundStyle(.red)
-                    .symbolSize(2)
-                }
-            }
-            .chartYAxis {
-                AxisMarks(
-                    values: [
-                        0,
-                        lowLimit,
-                        highLimit,
-                        state.units == .mmolL ? 15 : 270
-                    ]
-                )
-            }
-        }
-
-        var glucoseChart90: some View {
-            let lowLimit = (state.lowLimit ?? (4 * 0.0555)) * (state.units == .mmolL ? Decimal(conversionFactor) : 1)
-            let highLimit = (state.highLimit ?? (10 * 0.0555)) * (state.units == .mmolL ? Decimal(conversionFactor) : 1)
-            return Chart {
-                ForEach(fetchedGlucose.filter({ $0.glucose > Int(state.highLimit ?? 145) }), id: \.date) { item in
-                    PointMark(
-                        x: .value("Date", item.date ?? Date()),
-                        y: .value("Low", Double(item.glucose) * (state.units == .mmolL ? conversionFactor : 1))
-                    )
-                    .foregroundStyle(.orange)
-                    .symbolSize(2)
-                }
-                ForEach(
-                    fetchedGlucose
-                        .filter({ $0.glucose >= Int(state.lowLimit ?? 70) && $0.glucose <= Int(state.highLimit ?? 145) }),
-                    id: \.date
-                ) { item in
-                    PointMark(
-                        x: .value("Date", item.date ?? Date()),
-                        y: .value("In Range", Double(item.glucose) * (state.units == .mmolL ? conversionFactor : 1))
-                    )
-                    .foregroundStyle(.green)
-                    .symbolSize(2)
-                }
-                ForEach(fetchedGlucose.filter({ $0.glucose < Int(state.lowLimit ?? 70) }), id: \.date) { item in
-                    PointMark(
-                        x: .value("Date", item.date ?? Date()),
-                        y: .value("High", Double(item.glucose) * (state.units == .mmolL ? conversionFactor : 1))
-                    )
-                    .foregroundStyle(.red)
-                    .symbolSize(2)
-                }
-            }
-            .chartYAxis {
-                AxisMarks(
-                    values: [
-                        0,
-                        lowLimit,
-                        highLimit,
-                        state.units == .mmolL ? 15 : 270
-                    ]
-                )
-            }
-        }
-
-        private func loopStats(_ loops: FetchedResults<LoopStatRecord>) -> [(double: Double, string: String)] {
-            guard (loops.first?.start) != nil else { return [] }
-
-            var i = 0.0
-            var minimumInt = 999.0
-            var maximumInt = 0.0
-            var timeIntervalLoops = 0.0
-            var previousTimeLoop = loops.first?.end ?? Date()
-            var timeIntervalLoopArray: [Double] = []
-
-            let durationArray = loops.compactMap({ each in each.duration })
-            let durationArrayCount = durationArray.count
-            // var durationAverage = durationArray.reduce(0, +) / Double(durationArrayCount)
-
-            let medianDuration = medianCalculationDouble(array: durationArray)
-            let successsNR = loops.compactMap({ each in each.loopStatus }).filter({ each in each!.contains("Success") }).count
-            let errorNR = durationArrayCount - successsNR
-            let successRate: Double? = (Double(successsNR) / Double(successsNR + errorNR)) * 100
-
-            for each in loops {
-                if let loopEnd = each.end {
-                    i += 1
-                    timeIntervalLoops = (previousTimeLoop - (each.start ?? previousTimeLoop)).timeInterval / 60
-
-                    if timeIntervalLoops > 0.0, i != 1 {
-                        timeIntervalLoopArray.append(timeIntervalLoops)
-                    }
-                    if timeIntervalLoops > maximumInt {
-                        maximumInt = timeIntervalLoops
-                    }
-                    if timeIntervalLoops < minimumInt, i != 1 {
-                        minimumInt = timeIntervalLoops
-                    }
-                    previousTimeLoop = loopEnd
-                }
-            }
-
-            // Average Loop Interval in minutes
-            let timeOfFirstIndex = loops.first?.start ?? Date()
-            let lastIndexWithTimestamp = loops.count - 1
-            let timeOfLastIndex = loops[lastIndexWithTimestamp].end ?? Date()
-            let averageInterval = (timeOfFirstIndex - timeOfLastIndex).timeInterval / 60 / Double(errorNR + successsNR)
-
-            if minimumInt == 999.0 {
-                minimumInt = 0.0
-            }
-
-            var array: [(double: Double, string: String)] = []
-
-            array.append((double: Double(successsNR + errorNR), string: "Loops"))
-            array.append((double: averageInterval, string: "Interval"))
-            array.append((double: medianDuration, string: "Duration"))
-            array.append((double: successRate ?? 100, string: "%"))
-
-            return array
-        }
-
-        private func medianCalculation(array: [Int]) -> Double {
-            guard !array.isEmpty else {
-                return 0
-            }
-            let sorted = array.sorted()
-            let length = array.count
-
-            if length % 2 == 0 {
-                return Double((sorted[length / 2 - 1] + sorted[length / 2]) / 2)
-            }
-            return Double(sorted[length / 2])
-        }
-
-        private func medianCalculationDouble(array: [Double]) -> Double {
-            guard !array.isEmpty else {
-                return 0
-            }
-            let sorted = array.sorted()
-            let length = array.count
-
-            if length % 2 == 0 {
-                return (sorted[length / 2 - 1] + sorted[length / 2]) / 2
-            }
-            return sorted[length / 2]
-        }
-
-        private func glucoseStats(_ glucose_90: FetchedResults<Readings>)
-            -> (ifcc: Double, ngsp: Double, average: Double, median: Double, sd: Double, cv: Double, readings: Double)
-        {
-            var numberOfDays: Double = 0
-            let endIndex = glucose_90.count - 1
-
-            if endIndex > 0 {
-                let firstElementTime = glucose_90[0].date ?? Date()
-                let lastElementTime = glucose_90[endIndex].date ?? Date()
-                numberOfDays = (firstElementTime - lastElementTime).timeInterval / 8.64E4
-            }
-            var duration = 1
-            var denominator: Double = 1
-
-            switch selectedDuration {
-            case .Today:
-                let minutesSinceMidnight = Calendar.current.component(.hour, from: Date()) * 60 + Calendar.current
-                    .component(.minute, from: Date())
-                duration = minutesSinceMidnight
-                denominator = 1
-            case .Day:
-                duration = 1 * 1440
-                denominator = 1
-            case .Week:
-                duration = 7 * 1440
-                if numberOfDays > 7 { denominator = 7 } else { denominator = numberOfDays }
-            case .Month:
-                duration = 30 * 1440
-                if numberOfDays > 30 { denominator = 30 } else { denominator = numberOfDays }
-            case .Total:
-                duration = 90 * 1440
-                if numberOfDays >= 90 { denominator = 90 } else { denominator = numberOfDays }
-            }
-
-            let timeAgo = Date().addingTimeInterval(-duration.minutes.timeInterval)
-            let glucose = glucose_90.filter({ ($0.date ?? Date()) >= timeAgo })
-
-            let justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
-            let sumReadings = justGlucoseArray.reduce(0, +)
-            let countReadings = justGlucoseArray.count
-
-            let glucoseAverage = Double(sumReadings) / Double(countReadings)
-            let medianGlucose = medianCalculation(array: justGlucoseArray)
-
-            var NGSPa1CStatisticValue = 0.0
-            var IFCCa1CStatisticValue = 0.0
-
-            if numberOfDays > 0 {
-                NGSPa1CStatisticValue = (glucoseAverage + 46.7) / 28.7 // NGSP (%)
-                IFCCa1CStatisticValue = 10.929 *
-                    (NGSPa1CStatisticValue - 2.152) // IFCC (mmol/mol)  A1C(mmol/mol) = 10.929 * (A1C(%) - 2.15)
-            }
-            var sumOfSquares = 0.0
-
-            for array in justGlucoseArray {
-                sumOfSquares += pow(Double(array) - Double(glucoseAverage), 2)
-            }
-            var sd = 0.0
-            var cv = 0.0
-
-            // Avoid division by zero
-            if glucoseAverage > 0 {
-                sd = sqrt(sumOfSquares / Double(countReadings))
-                cv = sd / Double(glucoseAverage) * 100
-            }
-
-            var output: (ifcc: Double, ngsp: Double, average: Double, median: Double, sd: Double, cv: Double, readings: Double)
-            output = (
-                ifcc: IFCCa1CStatisticValue,
-                ngsp: NGSPa1CStatisticValue,
-                average: glucoseAverage * (state.units == .mmolL ? conversionFactor : 1),
-                median: medianGlucose * (state.units == .mmolL ? conversionFactor : 1),
-                sd: sd * (state.units == .mmolL ? conversionFactor : 1), cv: cv,
-                readings: Double(countReadings) / denominator
-            )
-            return output
-        }
-
-        private func tir(_ glucose_90: FetchedResults<Readings>) -> [(decimal: Decimal, string: String)] {
-            var duration = 1
-
-            switch selectedDuration {
-            case .Today:
-                let minutesSinceMidnight = Calendar.current.component(.hour, from: Date()) * 60 + Calendar.current
-                    .component(.minute, from: Date())
-                duration = minutesSinceMidnight
-            case .Day:
-                duration = 1 * 1440
-            case .Week:
-                duration = 7 * 1440
-            case .Month:
-                duration = 30 * 1440
-            case .Total:
-                duration = 90 * 1440
-            }
-
-            let hypoLimit = Int(state.lowLimit ?? 70)
-            let hyperLimit = Int(state.highLimit ?? 145)
-
-            let timeAgo = Date().addingTimeInterval(-duration.minutes.timeInterval)
-            let glucose = glucose_90.filter({ ($0.date ?? Date()) >= timeAgo })
-
-            let justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
-            let totalReadings = justGlucoseArray.count
-
-            let hyperArray = glucose.filter({ $0.glucose >= hyperLimit })
-            let hyperReadings = hyperArray.compactMap({ each in each.glucose as Int16 }).count
-            let hyperPercentage = Double(hyperReadings) / Double(totalReadings) * 100
-
-            let hypoArray = glucose.filter({ $0.glucose <= hypoLimit })
-            let hypoReadings = hypoArray.compactMap({ each in each.glucose as Int16 }).count
-            let hypoPercentage = Double(hypoReadings) / Double(totalReadings) * 100
-
-            let tir = 100 - (hypoPercentage + hyperPercentage)
-
-            var array: [(decimal: Decimal, string: String)] = []
-            array.append((decimal: Decimal(hypoPercentage), string: "Low"))
-            array.append((decimal: Decimal(tir), string: "NormaL"))
-            array.append((decimal: Decimal(hyperPercentage), string: "High"))
-
-            return array
-        }
-
-        private func colorOfGlucose(_ index: Int) -> Color {
-            let whichIndex = index
-
-            switch whichIndex {
-            case 0:
-                return .red
-            case 1:
-                return .green
-            case 2:
-                return .orange
-            default:
-                return .primary
-            }
-        }
     }
 }

+ 298 - 0
FreeAPS/Sources/Modules/Stat/View/StatsView.swift

@@ -0,0 +1,298 @@
+//
+//  FilteredLoopsView.swift
+//  FreeAPS
+//
+//  Created by Jon Mårtensson on 2023-05-29.
+//
+import CoreData
+import SwiftDate
+import SwiftUI
+
+struct StatsView: View {
+    @FetchRequest var fetchRequest: FetchedResults<LoopStatRecord>
+    @FetchRequest var fetchRequestReadings: FetchedResults<Readings>
+
+    @State var headline: Color = .secondary
+
+    @Binding var highLimit: Decimal
+    @Binding var lowLimit: Decimal
+    @Binding var units: GlucoseUnits
+    @Binding var overrideUnit: Bool
+
+    private let conversionFactor = 0.0555
+
+    var body: some View {
+        VStack(spacing: 10) {
+            loops
+            Divider()
+            hba1c
+            Divider()
+            bloodGlucose
+        }
+    }
+
+    init(
+        filter: NSDate,
+        _ highLimit: Binding<Decimal>,
+        _ lowLimit: Binding<Decimal>,
+        _ units: Binding<GlucoseUnits>,
+        _ overrideUnit: Binding<Bool>
+    ) {
+        _fetchRequest = FetchRequest<LoopStatRecord>(
+            sortDescriptors: [NSSortDescriptor(key: "start", ascending: false)],
+            predicate: NSPredicate(format: "interval > 0 AND start > %@", filter)
+        )
+
+        _fetchRequestReadings = FetchRequest<Readings>(
+            sortDescriptors: [NSSortDescriptor(key: "date", ascending: false)],
+            predicate: NSPredicate(format: "glucose > 0 AND date > %@", filter)
+        )
+
+        _highLimit = highLimit
+        _lowLimit = lowLimit
+        _units = units
+        _overrideUnit = overrideUnit
+    }
+
+    var loops: some View {
+        VStack(spacing: 10) {
+            let loops = fetchRequest
+
+            // First date
+            let previous = loops.last?.end ?? Date()
+            // Last date (recent)
+            let current = loops.first?.start ?? Date()
+
+            // Total time in days
+            let totalTime = (current - previous).timeInterval / 8.64E4
+
+            let durationArray = loops.compactMap({ each in each.duration })
+            let durationArrayCount = durationArray.count
+            // var durationAverage = durationArray.reduce(0, +) / Double(durationArrayCount)
+            let medianDuration = medianCalculationDouble(array: durationArray)
+            let successsNR = loops.compactMap({ each in each.loopStatus }).filter({ each in each!.contains("Success") })
+                .count
+            let errorNR = durationArrayCount - successsNR
+            let successRate: Double? = (Double(successsNR) / Double(successsNR + errorNR)) * 100
+
+            let loopNr = totalTime <= 1 ? Double(successsNR + errorNR) : round(Double(successsNR + errorNR) / totalTime)
+
+            let intervalArray = loops.compactMap({ each in each.interval as Double })
+            let intervalAverage = intervalArray.reduce(0, +) / Double(intervalArray.count)
+            // let maximumInterval = intervalArray.max()
+            // let minimumInterval = intervalArray.min()
+
+            HStack(spacing: 35) {
+                VStack(spacing: 5) {
+                    Text("Loops").font(.subheadline).foregroundColor(headline)
+                    Text(loopNr.formatted())
+                }
+                VStack(spacing: 5) {
+                    Text("Interval").font(.subheadline).foregroundColor(headline)
+                    Text(intervalAverage.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + " min")
+                }
+                VStack(spacing: 5) {
+                    Text("Duration").font(.subheadline).foregroundColor(headline)
+                    Text(
+                        (medianDuration * 60)
+                            .formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + " s"
+                    )
+                }
+                VStack(spacing: 5) {
+                    Text("Sucess").font(.subheadline).foregroundColor(headline)
+                    Text(
+                        ((successRate ?? 100) / 100)
+                            .formatted(.percent.grouping(.never).rounded().precision(.fractionLength(1)))
+                    )
+                }
+            }
+        }
+    }
+
+    private func medianCalculation(array: [Int]) -> Double {
+        guard !array.isEmpty else {
+            return 0
+        }
+        let sorted = array.sorted()
+        let length = array.count
+
+        if length % 2 == 0 {
+            return Double((sorted[length / 2 - 1] + sorted[length / 2]) / 2)
+        }
+        return Double(sorted[length / 2])
+    }
+
+    private func medianCalculationDouble(array: [Double]) -> Double {
+        guard !array.isEmpty else {
+            return 0
+        }
+        let sorted = array.sorted()
+        let length = array.count
+
+        if length % 2 == 0 {
+            return (sorted[length / 2 - 1] + sorted[length / 2]) / 2
+        }
+        return sorted[length / 2]
+    }
+
+    var hba1c: some View {
+        HStack(spacing: 50) {
+            let useUnit: GlucoseUnits = (units == .mmolL && overrideUnit) ? .mgdL :
+                (units == .mgdL && overrideUnit || units == .mmolL) ? .mmolL : .mgdL
+            let hba1cs = glucoseStats()
+            let glucose = fetchRequestReadings
+            // First date
+            let previous = glucose.last?.date ?? Date()
+            // Last date (recent)
+            let current = glucose.first?.date ?? Date()
+            // Total time in days
+            let numberOfDays = (current - previous).timeInterval / 8.64E4
+
+            let hba1cString = (
+                useUnit == .mmolL ? hba1cs.ifcc
+                    .formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) : hba1cs.ngsp
+                    .formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))
+                    + " %"
+            )
+            VStack(spacing: 5) {
+                Text("HbA1C").font(.subheadline).foregroundColor(headline)
+                Text(hba1cString)
+            }
+            VStack(spacing: 5) {
+                Text("SD").font(.subheadline).foregroundColor(.secondary)
+                Text(
+                    hba1cs.sd
+                        .formatted(
+                            .number.grouping(.never).rounded()
+                                .precision(.fractionLength(units == .mmolL ? 1 : 0))
+                        )
+                )
+            }
+            VStack(spacing: 5) {
+                Text("CV").font(.subheadline).foregroundColor(.secondary)
+                Text(hba1cs.cv.formatted(.number.grouping(.never).rounded().precision(.fractionLength(0))))
+            }
+            VStack(spacing: 5) {
+                Text("Days").font(.subheadline).foregroundColor(.secondary)
+                Text(numberOfDays.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))))
+            }
+        }
+    }
+
+    var bloodGlucose: some View {
+        HStack(spacing: 30) {
+            let bgs = glucoseStats()
+
+            let glucose = fetchRequestReadings
+            // First date
+            let previous = glucose.last?.date ?? Date()
+            // Last date (recent)
+            let current = glucose.first?.date ?? Date()
+            // Total time in days
+            let numberOfDays = (current - previous).timeInterval / 8.64E4
+
+            VStack(spacing: 5) {
+                Text(numberOfDays < 1 ? "Readings today" : "Readings / 24h").font(.subheadline)
+                    .foregroundColor(.secondary)
+                Text(bgs.readings.formatted(.number.grouping(.never).rounded().precision(.fractionLength(0))))
+            }
+            VStack(spacing: 5) {
+                Text("Average").font(.subheadline).foregroundColor(headline)
+                Text(
+                    bgs.average
+                        .formatted(
+                            .number.grouping(.never).rounded()
+                                .precision(.fractionLength(units == .mmolL ? 1 : 0))
+                        )
+                )
+            }
+            VStack(spacing: 5) {
+                Text("Median").font(.subheadline).foregroundColor(.secondary)
+                Text(
+                    bgs.median
+                        .formatted(
+                            .number.grouping(.never).rounded()
+                                .precision(.fractionLength(units == .mmolL ? 1 : 0))
+                        )
+                )
+            }
+        }
+    }
+
+    private func glucoseStats()
+        -> (ifcc: Double, ngsp: Double, average: Double, median: Double, sd: Double, cv: Double, readings: Double)
+    {
+        let glucose = fetchRequestReadings
+        // First date
+        let previous = glucose.last?.date ?? Date()
+        // Last date (recent)
+        let current = glucose.first?.date ?? Date()
+        // Total time in days
+        let numberOfDays = (current - previous).timeInterval / 8.64E4
+
+        let denominator = numberOfDays < 1 ? 1 : numberOfDays
+
+        let justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
+        let sumReadings = justGlucoseArray.reduce(0, +)
+        let countReadings = justGlucoseArray.count
+
+        let glucoseAverage = Double(sumReadings) / Double(countReadings)
+        let medianGlucose = medianCalculation(array: justGlucoseArray)
+
+        var NGSPa1CStatisticValue = 0.0
+        var IFCCa1CStatisticValue = 0.0
+
+        if numberOfDays > 0 {
+            NGSPa1CStatisticValue = (glucoseAverage + 46.7) / 28.7 // NGSP (%)
+            IFCCa1CStatisticValue = 10.929 *
+                (NGSPa1CStatisticValue - 2.152) // IFCC (mmol/mol)  A1C(mmol/mol) = 10.929 * (A1C(%) - 2.15)
+        }
+        var sumOfSquares = 0.0
+
+        for array in justGlucoseArray {
+            sumOfSquares += pow(Double(array) - Double(glucoseAverage), 2)
+        }
+        var sd = 0.0
+        var cv = 0.0
+
+        // Avoid division by zero
+        if glucoseAverage > 0 {
+            sd = sqrt(sumOfSquares / Double(countReadings))
+            cv = sd / Double(glucoseAverage) * 100
+        }
+
+        var output: (ifcc: Double, ngsp: Double, average: Double, median: Double, sd: Double, cv: Double, readings: Double)
+        output = (
+            ifcc: IFCCa1CStatisticValue,
+            ngsp: NGSPa1CStatisticValue,
+            average: glucoseAverage * (units == .mmolL ? conversionFactor : 1),
+            median: medianGlucose * (units == .mmolL ? conversionFactor : 1),
+            sd: sd * (units == .mmolL ? conversionFactor : 1), cv: cv,
+            readings: Double(countReadings) / denominator
+        )
+        return output
+    }
+
+    private func tir() -> [(decimal: Decimal, string: String)] {
+        let glucose = fetchRequestReadings
+        let justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
+        let totalReadings = justGlucoseArray.count
+
+        let hyperArray = glucose.filter({ $0.glucose >= Int(highLimit) })
+        let hyperReadings = hyperArray.compactMap({ each in each.glucose as Int16 }).count
+        let hyperPercentage = Double(hyperReadings) / Double(totalReadings) * 100
+
+        let hypoArray = glucose.filter({ $0.glucose <= Int(lowLimit) })
+        let hypoReadings = hypoArray.compactMap({ each in each.glucose as Int16 }).count
+        let hypoPercentage = Double(hypoReadings) / Double(totalReadings) * 100
+
+        let tir = 100 - (hypoPercentage + hyperPercentage)
+
+        var array: [(decimal: Decimal, string: String)] = []
+        array.append((decimal: Decimal(hypoPercentage), string: "Low"))
+        array.append((decimal: Decimal(tir), string: "NormaL"))
+        array.append((decimal: Decimal(hyperPercentage), string: "High"))
+
+        return array
+    }
+}

+ 3 - 0
FreeAPS/Sources/Modules/WatchConfig/WatchConfigStateModel.swift

@@ -7,6 +7,7 @@ enum AwConfig: String, JSON, CaseIterable, Identifiable, Codable {
     case BGTarget
     case steps
     case isf
+    case override
 
     var displayName: String {
         switch self {
@@ -18,6 +19,8 @@ enum AwConfig: String, JSON, CaseIterable, Identifiable, Codable {
             return NSLocalizedString("Steps", comment: "")
         case .isf:
             return NSLocalizedString("ISF", comment: "")
+        case .override:
+            return NSLocalizedString("% Override", comment: "")
         }
     }
 }

+ 18 - 0
FreeAPS/Sources/Services/WatchManager/WatchManager.swift

@@ -1,3 +1,4 @@
+import CoreData
 import Foundation
 import Swinject
 import WatchConnectivity
@@ -18,6 +19,8 @@ final class BaseWatchManager: NSObject, WatchManager, Injectable {
     @Injected() private var tempTargetsStorage: TempTargetsStorage!
     @Injected() private var garmin: GarminManager!
 
+    let coredataContext = CoreDataStack.shared.persistentContainer.viewContext // newBackgroundContext()
+
     private var lifetime = Lifetime()
 
     init(resolver: Resolver, session: WCSession = .default) {
@@ -102,6 +105,21 @@ final class BaseWatchManager: NSObject, WatchManager, Injectable {
 
             self.state.isf = self.suggestion?.isf
 
+            var overrideArray = [Override]()
+            let requestOverrides = Override.fetchRequest() as NSFetchRequest<Override>
+            let sortOverride = NSSortDescriptor(key: "date", ascending: false)
+            requestOverrides.sortDescriptors = [sortOverride]
+            requestOverrides.fetchLimit = 1
+            try? overrideArray = self.coredataContext.fetch(requestOverrides)
+
+            if overrideArray.first?.enabled ?? false {
+                let percentString = "\((overrideArray.first?.percentage ?? 100).formatted(.number)) %"
+                self.state.override = percentString
+
+            } else {
+                self.state.override = "100 %"
+            }
+
             self.sendState()
         }
     }

+ 1 - 0
FreeAPSWatch WatchKit Extension/DataFlow.swift

@@ -22,6 +22,7 @@ struct WatchState: Codable {
     var eventualBGRaw: String?
     var displayOnWatch: AwConfig?
     var isf: Decimal?
+    var override: String?
 }
 
 struct TempTargetWatchPreset: Codable, Identifiable {

+ 16 - 0
FreeAPSWatch WatchKit Extension/Views/MainView.swift

@@ -185,6 +185,22 @@ struct MainView: View {
                             .foregroundColor(.white)
                             .minimumScaleFactor(0.5)
                     }
+                case .override:
+                    Spacer()
+                    let override: String = state.override != nil ? state.override! : "-"
+                    HStack {
+                        Image(systemName: "person.3.sequence.fill")
+                            .renderingMode(.template)
+                            .resizable()
+                            .frame(width: 24, height: 12)
+                            .foregroundColor(.blue)
+                        Text("\(override)")
+                            .fontWeight(.regular)
+                            .font(.caption2)
+                            .scaledToFill()
+                            .foregroundColor(.white)
+                            .minimumScaleFactor(0.5)
+                    }
                 }
             }
             Spacer()

+ 3 - 0
FreeAPSWatch WatchKit Extension/WatchStateModel.swift

@@ -9,6 +9,7 @@ enum AwConfig: String, CaseIterable, Identifiable, Codable {
     case BGTarget
     case steps
     case isf
+    case override
 }
 
 class WatchStateModel: NSObject, ObservableObject {
@@ -54,6 +55,7 @@ class WatchStateModel: NSObject, ObservableObject {
     @Published var pendingBolus: Double?
 
     @Published var isf: Decimal?
+    @Published var override: String?
 
     private var lifetime = Set<AnyCancellable>()
     private var confirmationTimeout: AnyCancellable?
@@ -172,6 +174,7 @@ class WatchStateModel: NSObject, ObservableObject {
         eventualBG = state.eventualBG ?? ""
         displayOnWatch = state.displayOnWatch ?? .BGTarget
         isf = state.isf
+        override = state.override
     }
 }