Explorar el Código

Merge pull request #417 from nightscout/add-ting-range

Add Time in Normoglycemia (TING) as UI option for Statistics
marv-out hace 1 año
padre
commit
6c1d636214

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -620,6 +620,7 @@
 		DDE1796F2C910127003CDDB7 /* OrefDetermination+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE1794F2C910127003CDDB7 /* OrefDetermination+CoreDataProperties.swift */; };
 		DDE179702C910127003CDDB7 /* OverrideStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179502C910127003CDDB7 /* OverrideStored+CoreDataClass.swift */; };
 		DDE179712C910127003CDDB7 /* OverrideStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179512C910127003CDDB7 /* OverrideStored+CoreDataProperties.swift */; };
+		DDEBB05C2D89E9050032305D /* TimeInRangeType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDEBB05B2D89E9050032305D /* TimeInRangeType.swift */; };
 		DDF847DD2C5C28720049BB3B /* LiveActivitySettingsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF847DC2C5C28720049BB3B /* LiveActivitySettingsDataFlow.swift */; };
 		DDF847DF2C5C28780049BB3B /* LiveActivitySettingsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF847DE2C5C28780049BB3B /* LiveActivitySettingsProvider.swift */; };
 		DDF847E12C5C287F0049BB3B /* LiveActivitySettingsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF847E02C5C287F0049BB3B /* LiveActivitySettingsStateModel.swift */; };
@@ -1385,6 +1386,7 @@
 		DDE1794F2C910127003CDDB7 /* OrefDetermination+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OrefDetermination+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		DDE179502C910127003CDDB7 /* OverrideStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OverrideStored+CoreDataClass.swift"; sourceTree = "<group>"; };
 		DDE179512C910127003CDDB7 /* OverrideStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OverrideStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
+		DDEBB05B2D89E9050032305D /* TimeInRangeType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeInRangeType.swift; sourceTree = "<group>"; };
 		DDF847DC2C5C28720049BB3B /* LiveActivitySettingsDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettingsDataFlow.swift; sourceTree = "<group>"; };
 		DDF847DE2C5C28780049BB3B /* LiveActivitySettingsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettingsProvider.swift; sourceTree = "<group>"; };
 		DDF847E02C5C287F0049BB3B /* LiveActivitySettingsStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettingsStateModel.swift; sourceTree = "<group>"; };
@@ -2212,6 +2214,7 @@
 		388E5A5925B6F0250019842D /* Models */ = {
 			isa = PBXGroup;
 			children = (
+				DDEBB05B2D89E9050032305D /* TimeInRangeType.swift */,
 				3B2F77852D7E52ED005ED9FA /* TDD.swift */,
 				DD4FFF322D458EE600B6CFF9 /* GarminWatchState.swift */,
 				DD3078692D42F94000DE0490 /* GarminDevice.swift */,
@@ -4160,6 +4163,7 @@
 				CE7CA3542A064973004BE681 /* TempPresetsIntentRequest.swift in Sources */,
 				58A3D5442C96DE11003F90FC /* TempTargetStored+Helper.swift in Sources */,
 				DD6B7CB42C7B71F700B75029 /* ForecastDisplayType.swift in Sources */,
+				DDEBB05C2D89E9050032305D /* TimeInRangeType.swift in Sources */,
 				DD5DC9F32CF3D9DD00AB8703 /* AdjustmentsStateModel+TempTargets.swift in Sources */,
 				F5F7E6C1B7F098F59EB67EC5 /* TargetsEditorDataFlow.swift in Sources */,
 				DD17453A2C55BFA600211FAC /* AlgorithmAdvancedSettingsDataFlow.swift in Sources */,

+ 2 - 1
Trio/Resources/json/defaults/freeaps/freeaps_settings.json

@@ -47,5 +47,6 @@
   "lockScreenView": "simple",
   "useCalendar": false,
   "displayCalendarIOBandCOB": false,
-  "displayCalendarEmojis": false
+  "displayCalendarEmojis": false,
+  "timeInRangeType": "timeInTightRange"
 }

+ 31 - 10
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -5695,6 +5695,16 @@
         }
       }
     },
+    "%@ (%@-%@)" : {
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "new",
+            "value" : "%1$@ (%2$@-%3$@)"
+          }
+        }
+      }
+    },
     "%@ %@" : {
       "localizations" : {
         "bg" : {
@@ -48589,6 +48599,9 @@
         }
       }
     },
+    "Choose type of time in range to be used for Trio's statistics." : {
+
+    },
     "Choose whether or not to display one or both X- and Y-Axis grid lines." : {
       "localizations" : {
         "bg" : {
@@ -48995,6 +49008,9 @@
         }
       }
     },
+    "Choose which type of time in range Trio should adopt for all its statistical charts and displays:" : {
+
+    },
     "Clear" : {
       "comment" : "Button",
       "extractionState" : "manual",
@@ -176786,16 +176802,6 @@
         }
       }
     },
-    "Tight (%@-%@)" : {
-      "localizations" : {
-        "en" : {
-          "stringUnit" : {
-            "state" : "new",
-            "value" : "Tight (%1$@-%2$@)"
-          }
-        }
-      }
-    },
     "Time" : {
       "comment" : "Time basal profile",
       "localizations" : {
@@ -176997,6 +177003,12 @@
         }
       }
     },
+    "Time in Normoglycemia (TING)" : {
+
+    },
+    "Time in Normoglycemia (TING):" : {
+
+    },
     "Time in Range Chart Style" : {
       "extractionState" : "stale",
       "localizations" : {
@@ -177098,6 +177110,15 @@
         }
       }
     },
+    "Time in Range Type" : {
+
+    },
+    "Time in Tight Range (TITR)" : {
+
+    },
+    "Time in Tight Range (TITR):" : {
+
+    },
     "Time interval between FPUs." : {
       "localizations" : {
         "bg" : {

+ 34 - 0
Trio/Sources/Models/TimeInRangeType.swift

@@ -0,0 +1,34 @@
+import Foundation
+
+enum TimeInRangeType: String, JSON, CaseIterable, Identifiable, Codable, Hashable {
+    var id: String { rawValue }
+    case timeInTightRange
+    case timeInNormoglycemia
+
+    var displayName: String {
+        switch self {
+        case .timeInTightRange:
+            return String(localized: "Time in Tight Range (TITR)", comment: "")
+
+        case .timeInNormoglycemia:
+            return String(localized: "Time in Normoglycemia (TING)", comment: "")
+        }
+    }
+
+    var bottomThreshold: Int {
+        switch self {
+        case .timeInTightRange:
+            return 70
+        case .timeInNormoglycemia:
+            return 63
+        }
+    }
+
+    var topThreshold: Int {
+        switch self {
+        case .timeInNormoglycemia,
+             .timeInTightRange:
+            return 140
+        }
+    }
+}

+ 5 - 0
Trio/Sources/Models/TrioSettings.swift

@@ -69,6 +69,7 @@ struct TrioSettings: JSON, Equatable {
     var useLiveActivity: Bool = false
     var lockScreenView: LockScreenView = .simple
     var bolusShortcut: BolusShortcutLimit = .notAllowed
+    var timeInRangeType: TimeInRangeType = .timeInTightRange
 }
 
 extension TrioSettings: Decodable {
@@ -295,6 +296,10 @@ extension TrioSettings: Decodable {
             settings.bolusShortcut = bolusShortcut
         }
 
+        if let timeInRangeType = try? container.decode(TimeInRangeType.self, forKey: .timeInRangeType) {
+            settings.timeInRangeType = timeInRangeType
+        }
+
         self = settings
     }
 }

+ 6 - 3
Trio/Sources/Modules/Stat/StatStateModel+Setup/StackedChartSetup.swift

@@ -93,9 +93,12 @@ extension Stat.StateModel {
             // Ranges are processed from bottom to top in the stacked chart
             let ranges: [(name: String, condition: (Int) -> Bool)] = [
                 ("<54", { g in g <= 54 }),
-                ("54-70", { g in g > 54 && g < 70 }),
-                ("70-140", { g in g >= 70 && g <= 140 }),
-                ("140-180", { g in g > 140 && g <= 180 }),
+                ("54-\(self.timeInRangeType.bottomThreshold)", { g in g > 54 && g < self.timeInRangeType.bottomThreshold }),
+                (
+                    "\(self.timeInRangeType.bottomThreshold)-\(self.timeInRangeType.topThreshold)",
+                    { g in g >= self.timeInRangeType.bottomThreshold && g <= self.timeInRangeType.topThreshold }
+                ),
+                ("\(self.timeInRangeType.topThreshold)-180", { g in g > self.timeInRangeType.topThreshold && g <= 180 }),
                 ("180-200", { g in g > 180 && g <= 200 }),
                 ("200-220", { g in g > 200 && g <= 220 }),
                 (">220", { g in g > 220 })

+ 2 - 0
Trio/Sources/Modules/Stat/StatStateModel.swift

@@ -11,6 +11,7 @@ extension Stat {
         var lowLimit: Decimal = 70
         var eA1cDisplayUnit: EstimatedA1cDisplayUnit = .percent
         var units: GlucoseUnits = .mgdL
+        var timeInRangeType: TimeInRangeType = .timeInTightRange
         var useFPUconversion: Bool = false
         var glucoseFromPersistence: [GlucoseStored] = []
         var loopStatRecords: [LoopStatRecord] = []
@@ -85,6 +86,7 @@ extension Stat {
             units = settingsManager.settings.units
             eA1cDisplayUnit = settingsManager.settings.eA1cDisplayUnit
             useFPUconversion = settingsManager.settings.useFPUconversion
+            timeInRangeType = settingsManager.settings.timeInRangeType
         }
 
         func setupGlucoseArray(for interval: StatsTimeIntervalWithToday) {

+ 4 - 2
Trio/Sources/Modules/Stat/View/StatRootView.swift

@@ -130,7 +130,8 @@ extension Stat {
                             highLimit: state.highLimit,
                             lowLimit: state.lowLimit,
                             units: state.units,
-                            glucoseRangeStats: state.glucoseRangeStats
+                            glucoseRangeStats: state.glucoseRangeStats,
+                            timeInRangeType: state.timeInRangeType
                         )
                     }
                 }
@@ -144,7 +145,8 @@ extension Stat {
                         highLimit: state.highLimit,
                         lowLimit: state.lowLimit,
                         units: state.units,
-                        glucose: state.glucoseFromPersistence
+                        glucose: state.glucoseFromPersistence,
+                        timeInRangeType: state.timeInRangeType
                     )
 
                     Divider()

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

@@ -7,6 +7,7 @@ struct GlucoseDistributionChart: View {
     let lowLimit: Decimal
     let units: GlucoseUnits
     let glucoseRangeStats: [GlucoseRangeStats]
+    let timeInRangeType: TimeInRangeType
 
     var body: some View {
         VStack(alignment: .leading, spacing: 8) {
@@ -25,9 +26,9 @@ struct GlucoseDistributionChart: View {
             }
             .chartForegroundStyleScale([
                 "<54": .purple.opacity(0.7),
-                "54-70": .red.opacity(0.7),
-                "70-140": .green,
-                "140-180": .green.opacity(0.7),
+                "54-\(timeInRangeType.bottomThreshold)": .red.opacity(0.7),
+                "\(timeInRangeType.bottomThreshold)-\(timeInRangeType.topThreshold)": .green,
+                "\(timeInRangeType.topThreshold)-180": .green.opacity(0.7),
                 "180-200": .yellow.opacity(0.7),
                 "200-220": .orange.opacity(0.7),
                 ">220": .orange.opacity(0.8)
@@ -36,12 +37,15 @@ struct GlucoseDistributionChart: View {
                 let legendItems: [(String, Color)] = [
                     ("<\(units == .mgdL ? Decimal(54) : 54.asMmolL)", .purple.opacity(0.7)),
                     (
-                        "\(units == .mgdL ? Decimal(54) : 54.asMmolL)-\(units == .mgdL ? Decimal(70) : 70.asMmolL)",
+                        "\(units == .mgdL ? Decimal(54) : 54.asMmolL)-\(units == .mgdL ? Decimal(timeInRangeType.bottomThreshold) : timeInRangeType.bottomThreshold.asMmolL)",
                         .red.opacity(0.7)
                     ),
-                    ("\(units == .mgdL ? Decimal(70) : 70.asMmolL)-\(units == .mgdL ? Decimal(140) : 140.asMmolL)", .green),
                     (
-                        "\(units == .mgdL ? Decimal(140) : 140.asMmolL)-\(units == .mgdL ? Decimal(180) : 180.asMmolL)",
+                        "\(units == .mgdL ? Decimal(timeInRangeType.bottomThreshold) : timeInRangeType.bottomThreshold.asMmolL)-\(units == .mgdL ? Decimal(timeInRangeType.topThreshold) : timeInRangeType.topThreshold.asMmolL)",
+                        .green
+                    ),
+                    (
+                        "\(units == .mgdL ? Decimal(timeInRangeType.topThreshold) : timeInRangeType.topThreshold.asMmolL)-\(units == .mgdL ? Decimal(180) : 180.asMmolL)",
                         .green.opacity(0.7)
                     ),
                     (

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

@@ -8,6 +8,7 @@ struct GlucoseSectorChart: View {
     let lowLimit: Decimal
     let units: GlucoseUnits
     let glucose: [GlucoseStored]
+    let timeInRangeType: TimeInRangeType
 
     @State private var selectedCount: Int?
     @State private var selectedRange: GlucoseRange?
@@ -28,8 +29,9 @@ struct GlucoseSectorChart: View {
             let total = Decimal(glucose.count)
             // Count readings between high limit and 250 mg/dL (high)
             let high = glucose.filter { $0.glucose > Int(highLimit) }.count
-            // Count readings between low limit and 140 mg/dL (tight control)
-            let tight = glucose.filter { $0.glucose >= Int(lowLimit) && $0.glucose <= 140 }.count
+            // Count readings between low limit (TITR: 70 mg/dL, TING 63 mg/dL) and 140 mg/dL (tight control)
+            let tight = glucose
+                .filter { $0.glucose >= timeInRangeType.bottomThreshold && $0.glucose <= timeInRangeType.topThreshold }.count
             // Count readings between 140 and high limit (normal range)
             let normal = glucose.filter { $0.glucose >= Int(lowLimit) && $0.glucose <= Int(highLimit) }.count
             // Count readings between 54 and low limit (low)
@@ -54,7 +56,11 @@ struct GlucoseSectorChart: View {
                 }
 
                 VStack(alignment: .leading, spacing: 5) {
-                    Text("\(formatValue(lowLimit))-\(formatValue(140))").font(.subheadline).foregroundStyle(Color.secondary)
+                    Text(
+                        "\(formatValue(Decimal(timeInRangeType.bottomThreshold)))-\(formatValue(Decimal(timeInRangeType.topThreshold)))"
+                    )
+                    .font(.subheadline)
+                    .foregroundStyle(Color.secondary)
                     Text(tightPercentage.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%")
                         .foregroundStyle(Color.green)
                 }
@@ -219,7 +225,8 @@ struct GlucoseSectorChart: View {
             )
 
         case .inRange:
-            let tight = glucose.filter { $0.glucose >= Int(lowLimit) && $0.glucose <= 140 }.count
+            let tight = glucose
+                .filter { $0.glucose >= Int(timeInRangeType.bottomThreshold) && $0.glucose <= timeInRangeType.topThreshold }.count
             let glucoseValues = glucose.filter { $0.glucose >= Int(lowLimit) && $0.glucose <= Int(highLimit) }
             let glucoseValuesAsInt = glucoseValues.map { Int($0.glucose) }
             let (average, median, standardDeviation) = calculateDetailedStatistics(for: glucoseValuesAsInt)
@@ -233,7 +240,9 @@ struct GlucoseSectorChart: View {
                         formatPercentage(Decimal(glucoseValues.count) / total * 100)
                     ),
                     (
-                        String(localized: "Tight (\(formatValue(lowLimit))-\(formatValue(140)))"),
+                        String(
+                            localized: "\(timeInRangeType == .timeInTightRange ? "TITR" : "TING") (\(formatValue(Decimal(timeInRangeType.bottomThreshold)))-\(formatValue(Decimal(timeInRangeType.topThreshold))))"
+                        ),
                         formatPercentage(Decimal(tight) / total * 100)
                     ),
                     (String(localized: "Average"), formatValue(average)),

+ 3 - 0
Trio/Sources/Modules/UserInterfaceSettings/UserInterfaceSettingsStateModel.swift

@@ -12,6 +12,7 @@ extension UserInterfaceSettings {
         @Published var carbsRequiredThreshold: Decimal = 0
         @Published var glucoseColorScheme: GlucoseColorScheme = .staticColor
         @Published var eA1cDisplayUnit: EstimatedA1cDisplayUnit = .percent
+        @Published var timeInRangeType: TimeInRangeType = .timeInTightRange
 
         var units: GlucoseUnits = .mgdL
 
@@ -39,6 +40,8 @@ extension UserInterfaceSettings {
             subscribeSetting(\.glucoseColorScheme, on: $glucoseColorScheme) { glucoseColorScheme = $0 }
 
             subscribeSetting(\.eA1cDisplayUnit, on: $eA1cDisplayUnit) { eA1cDisplayUnit = $0 }
+
+            subscribeSetting(\.timeInRangeType, on: $timeInRangeType) { timeInRangeType = $0 }
         }
     }
 }

+ 69 - 0
Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift

@@ -419,6 +419,75 @@ extension UserInterfaceSettings {
                     }
                 ).listRowBackground(Color.chart)
 
+                Section {
+                    VStack(alignment: .leading) {
+                        Picker(
+                            selection: $state.timeInRangeType,
+                            label: Text("Time in Range Type").multilineTextAlignment(.leading)
+                        ) {
+                            ForEach(TimeInRangeType.allCases) { selection in
+                                Text(selection.displayName).tag(selection)
+                            }
+                        }.padding(.top)
+
+                        HStack(alignment: .center) {
+                            Text(
+                                "Choose type of time in range to be used for Trio's statistics."
+                            )
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                            .lineLimit(nil)
+                            Spacer()
+                            Button(
+                                action: {
+                                    hintLabel = String(localized: "Time in Range Type")
+                                    selectedVerboseHint =
+                                        AnyView(
+                                            VStack(
+                                                alignment: .leading,
+                                                spacing: 10
+                                            ) {
+                                                Text(
+                                                    "Choose which type of time in range Trio should adopt for all its statistical charts and displays:"
+                                                )
+                                                VStack(
+                                                    alignment: .leading,
+                                                    spacing: 5
+                                                ) {
+                                                    Text(
+                                                        "Time in Tight Range (TITR):"
+                                                    )
+                                                    .bold()
+                                                    Text(
+                                                        "Uses the fairly established Time in Tight Range definition, which is defined as time between \(state.units == .mgdL ? Decimal(70) : 70.asMmolL) and \(state.units == .mgdL ? Decimal(140) : 140.asMmolL) \(state.units.rawValue)."
+                                                    )
+                                                }
+                                                VStack(
+                                                    alignment: .leading,
+                                                    spacing: 5
+                                                ) {
+                                                    Text(
+                                                        "Time in Normoglycemia (TING):"
+                                                    )
+                                                    .bold()
+                                                    Text(
+                                                        "Uses the very new – first discussed at ATTD 2025 in Amsterdam, NL – Time in Normoglycemia definition, which adopts its range as all values between the normoglycemic minimum threshold (\(state.units == .mgdL ? Decimal(63) : 63.asMmolL) \(state.units.rawValue)) and \(state.units == .mgdL ? Decimal(140) : 140.asMmolL) \(state.units.rawValue)."
+                                                    )
+                                                }
+                                            }
+                                        )
+                                    shouldDisplayHint.toggle()
+                                },
+                                label: {
+                                    HStack {
+                                        Image(systemName: "questionmark.circle")
+                                    }
+                                }
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.top)
+                    }.padding(.bottom)
+                }.listRowBackground(Color.chart)
+
                 SettingInputSection(
                     decimalValue: $state.carbsRequiredThreshold,
                     booleanValue: $state.showCarbsRequiredBadge,