Sfoglia il codice sorgente

Add color thresholds to the Info Display (#693)

* Add color thresholds to the Info Display

Individual info rows (IOB, COB, battery, pump battery, TDD, rec bolus,
carbs, and sensor/cannula/insulin ages) can now turn yellow or red when
their value crosses user-set thresholds — a visual cue only, with no
alarm. The concerning direction is fixed per metric (battery colors when
low, ages when high) and in-range values read green.

Replaces the parallel infoSort/infoVisible arrays with a single ordered
[InfoDisplayItem] store (migrated via migrateStep10), and reworks the
Info Display settings into drill-in rows that reorder and open a per-row
detail at the same time.

* Use steppers for Info Display color thresholds, and color the pump row

Threshold entry now uses steppers with a per-type range and step, mirroring
the alarm editors, so values that need decimals (IOB, rec. bolus, the ages)
accept them. The old text fields re-parsed on every keystroke and swallowed
the decimal separator, making fractional thresholds impossible to enter.

Direction, unit, bounds and defaults move into a single InfoColorConfig per
type, so isColorable is simply "has a config".

The pump reservoir row is now colorable, low-is-concerning. Pumps that only
report "50+" count as exactly 50, matching what the reservoir alarm already
does with latestPumpVolume.
Jonas Björkert 1 settimana fa
parent
commit
c56d0b3645

+ 8 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -115,6 +115,8 @@
 		DD13BC752C3FD6210062313B /* InfoType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC742C3FD6200062313B /* InfoType.swift */; };
 		DD13BC752C3FD6210062313B /* InfoType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC742C3FD6200062313B /* InfoType.swift */; };
 		DD13BC772C3FD64E0062313B /* InfoData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC762C3FD64E0062313B /* InfoData.swift */; };
 		DD13BC772C3FD64E0062313B /* InfoData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC762C3FD64E0062313B /* InfoData.swift */; };
 		DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC782C3FE63A0062313B /* InfoManager.swift */; };
 		DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC782C3FE63A0062313B /* InfoManager.swift */; };
+		DDA0000012341234000A0002 /* InfoDisplayItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA0000012341234000A0001 /* InfoDisplayItem.swift */; };
+		DDA0000012341234000B0002 /* InfoRowSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA0000012341234000B0001 /* InfoRowSettingsView.swift */; };
 		DD13BC7B2C3FE64A0062313B /* InfoTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */; };
 		DD13BC7B2C3FE64A0062313B /* InfoTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */; };
 		DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */; };
 		DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */; };
 		DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */; };
 		DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */; };
@@ -583,6 +585,8 @@
 		DD13BC742C3FD6200062313B /* InfoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoType.swift; sourceTree = "<group>"; };
 		DD13BC742C3FD6200062313B /* InfoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoType.swift; sourceTree = "<group>"; };
 		DD13BC762C3FD64E0062313B /* InfoData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoData.swift; sourceTree = "<group>"; };
 		DD13BC762C3FD64E0062313B /* InfoData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoData.swift; sourceTree = "<group>"; };
 		DD13BC782C3FE63A0062313B /* InfoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoManager.swift; sourceTree = "<group>"; };
 		DD13BC782C3FE63A0062313B /* InfoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoManager.swift; sourceTree = "<group>"; };
+		DDA0000012341234000A0001 /* InfoDisplayItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoDisplayItem.swift; sourceTree = "<group>"; };
+		DDA0000012341234000B0001 /* InfoRowSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoRowSettingsView.swift; sourceTree = "<group>"; };
 		DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoTableView.swift; sourceTree = "<group>"; };
 		DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoTableView.swift; sourceTree = "<group>"; };
 		DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorageValue.swift; sourceTree = "<group>"; };
 		DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorageValue.swift; sourceTree = "<group>"; };
 		DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKQuantityInputView.swift; sourceTree = "<group>"; };
 		DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKQuantityInputView.swift; sourceTree = "<group>"; };
@@ -1183,6 +1187,7 @@
 				DD13BC742C3FD6200062313B /* InfoType.swift */,
 				DD13BC742C3FD6200062313B /* InfoType.swift */,
 				DD13BC762C3FD64E0062313B /* InfoData.swift */,
 				DD13BC762C3FD64E0062313B /* InfoData.swift */,
 				DD13BC782C3FE63A0062313B /* InfoManager.swift */,
 				DD13BC782C3FE63A0062313B /* InfoManager.swift */,
+				DDA0000012341234000A0001 /* InfoDisplayItem.swift */,
 				DD0C0C652C46E54C00DBADDF /* InfoDataSeparator.swift */,
 				DD0C0C652C46E54C00DBADDF /* InfoDataSeparator.swift */,
 				DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */,
 				DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */,
 			);
 			);
@@ -1265,6 +1270,7 @@
 			children = (
 			children = (
 				DD5334262C61668700062F9D /* InfoDisplaySettingsViewModel.swift */,
 				DD5334262C61668700062F9D /* InfoDisplaySettingsViewModel.swift */,
 				DD5334282C6166A500062F9D /* InfoDisplaySettingsView.swift */,
 				DD5334282C6166A500062F9D /* InfoDisplaySettingsView.swift */,
+				DDA0000012341234000B0001 /* InfoRowSettingsView.swift */,
 			);
 			);
 			path = InfoDisplaySettings;
 			path = InfoDisplaySettings;
 			sourceTree = "<group>";
 			sourceTree = "<group>";
@@ -2331,6 +2337,8 @@
 				DD7F4BA12DD2193F00D449E9 /* AlarmSnoozeSection.swift in Sources */,
 				DD7F4BA12DD2193F00D449E9 /* AlarmSnoozeSection.swift in Sources */,
 				DD9ACA0C2D33BB8600415D8A /* CalendarTask.swift in Sources */,
 				DD9ACA0C2D33BB8600415D8A /* CalendarTask.swift in Sources */,
 				DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */,
 				DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */,
+				DDA0000012341234000A0002 /* InfoDisplayItem.swift in Sources */,
+				DDA0000012341234000B0002 /* InfoRowSettingsView.swift in Sources */,
 				DD13BC7B2C3FE64A0062313B /* InfoTableView.swift in Sources */,
 				DD13BC7B2C3FE64A0062313B /* InfoTableView.swift in Sources */,
 				DD4E5F6A7B8C9D0E2F2A3B4C /* RemoteContentView.swift in Sources */,
 				DD4E5F6A7B8C9D0E2F2A3B4C /* RemoteContentView.swift in Sources */,
 				DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */,
 				DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */,

+ 1 - 1
LoopFollow/Controllers/Nightscout/CAge.swift

@@ -51,7 +51,7 @@ extension MainViewController {
             formatter.zeroFormattingBehavior = [.pad] // Pad with zeroes where appropriate for the locale
             formatter.zeroFormattingBehavior = [.pad] // Pad with zeroes where appropriate for the locale
 
 
             if let formattedDuration = formatter.string(from: secondsAgo) {
             if let formattedDuration = formatter.string(from: secondsAgo) {
-                infoManager.updateInfoData(type: .cage, value: formattedDuration)
+                infoManager.updateInfoData(type: .cage, value: formattedDuration, numericValue: secondsAgo / 86400)
             }
             }
         }
         }
     }
     }

+ 6 - 4
LoopFollow/Controllers/Nightscout/DeviceStatus.swift

@@ -114,11 +114,13 @@ extension MainViewController {
 
 
                 if let reservoirData = lastPumpRecord["reservoir"] as? Double {
                 if let reservoirData = lastPumpRecord["reservoir"] as? Double {
                     latestPumpVolume = reservoirData
                     latestPumpVolume = reservoirData
-                    infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U")
+                    infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U", numericValue: reservoirData)
                     Storage.shared.lastPumpReservoirU.value = reservoirData
                     Storage.shared.lastPumpReservoirU.value = reservoirData
                 } else {
                 } else {
+                    // Pumps that only report "50+" get treated as exactly 50, both
+                    // for the volume alarm and for the info row's coloring.
                     latestPumpVolume = 50.0
                     latestPumpVolume = 50.0
-                    infoManager.updateInfoData(type: .pump, value: "50+U")
+                    infoManager.updateInfoData(type: .pump, value: "50+U", numericValue: 50.0)
                     Storage.shared.lastPumpReservoirU.value = nil
                     Storage.shared.lastPumpReservoirU.value = nil
                 }
                 }
             }
             }
@@ -127,7 +129,7 @@ extension MainViewController {
             if let pumpBatteryRecord = lastPumpRecord["battery"] as? [String: AnyObject],
             if let pumpBatteryRecord = lastPumpRecord["battery"] as? [String: AnyObject],
                let pumpBatteryPercent = pumpBatteryRecord["percent"] as? Double
                let pumpBatteryPercent = pumpBatteryRecord["percent"] as? Double
             {
             {
-                infoManager.updateInfoData(type: .pumpBattery, value: String(format: "%.0f", pumpBatteryPercent) + "%")
+                infoManager.updateInfoData(type: .pumpBattery, value: String(format: "%.0f", pumpBatteryPercent) + "%", numericValue: pumpBatteryPercent)
                 Observable.shared.pumpBatteryLevel.value = pumpBatteryPercent
                 Observable.shared.pumpBatteryLevel.value = pumpBatteryPercent
             }
             }
 
 
@@ -141,7 +143,7 @@ extension MainViewController {
                 } else {
                 } else {
                     batteryText = String(format: "%.0f", upbat) + "%"
                     batteryText = String(format: "%.0f", upbat) + "%"
                 }
                 }
-                infoManager.updateInfoData(type: .battery, value: batteryText)
+                infoManager.updateInfoData(type: .battery, value: batteryText, numericValue: upbat)
                 Observable.shared.deviceBatteryLevel.value = upbat
                 Observable.shared.deviceBatteryLevel.value = upbat
                 Observable.shared.deviceBatteryIsCharging.value = isCharging
                 Observable.shared.deviceBatteryIsCharging.value = isCharging
 
 

+ 1 - 1
LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift

@@ -101,7 +101,7 @@ extension MainViewController {
                 updatePredictionGraph()
                 updatePredictionGraph()
             }
             }
             if let recBolus = lastLoopRecord["recommendedBolus"] as? Double {
             if let recBolus = lastLoopRecord["recommendedBolus"] as? Double {
-                infoManager.updateInfoData(type: .recBolus, value: InsulinFormatter.shared.string(recBolus))
+                infoManager.updateInfoData(type: .recBolus, value: InsulinFormatter.shared.string(recBolus), numericValue: recBolus)
                 Observable.shared.deviceRecBolus.value = recBolus
                 Observable.shared.deviceRecBolus.value = recBolus
             } else {
             } else {
                 infoManager.clearInfoData(type: .recBolus)
                 infoManager.clearInfoData(type: .recBolus)

+ 1 - 1
LoopFollow/Controllers/Nightscout/IAge.swift

@@ -55,7 +55,7 @@ extension MainViewController {
             formatter.zeroFormattingBehavior = [.pad]
             formatter.zeroFormattingBehavior = [.pad]
 
 
             if let formattedDuration = formatter.string(from: secondsAgo) {
             if let formattedDuration = formatter.string(from: secondsAgo) {
-                infoManager.updateInfoData(type: .iage, value: formattedDuration)
+                infoManager.updateInfoData(type: .iage, value: formattedDuration, numericValue: secondsAgo / 86400)
             }
             }
         }
         }
     }
     }

+ 1 - 1
LoopFollow/Controllers/Nightscout/SAge.swift

@@ -72,7 +72,7 @@ extension MainViewController {
             formatter.zeroFormattingBehavior = [.pad] // Pad with zeroes where appropriate for the locale
             formatter.zeroFormattingBehavior = [.pad] // Pad with zeroes where appropriate for the locale
 
 
             if let formattedDuration = formatter.string(from: secondsAgo) {
             if let formattedDuration = formatter.string(from: secondsAgo) {
-                infoManager.updateInfoData(type: .sage, value: formattedDuration)
+                infoManager.updateInfoData(type: .sage, value: formattedDuration, numericValue: secondsAgo / 86400)
             }
             }
         }
         }
     }
     }

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

@@ -81,7 +81,7 @@ extension MainViewController {
         }
         }
 
 
         let resultString = String(format: "%.0f", totalCarbs)
         let resultString = String(format: "%.0f", totalCarbs)
-        infoManager.updateInfoData(type: .carbsToday, value: resultString)
+        infoManager.updateInfoData(type: .carbsToday, value: resultString, numericValue: totalCarbs)
         Storage.shared.lastCarbsToday.value = totalCarbs
         Storage.shared.lastCarbsToday.value = totalCarbs
     }
     }
 }
 }

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

@@ -9,7 +9,7 @@ extension MainViewController {
             updateIage(data: entries)
             updateIage(data: entries)
         } else if let iage = currentIage {
         } else if let iage = currentIage {
             updateIage(data: [iage])
             updateIage(data: [iage])
-        } else if Storage.shared.infoVisible.value[InfoType.iage.rawValue] {
+        } else if Storage.shared.infoDisplayItems.value.isVisible(.iage) {
             webLoadNSIage()
             webLoadNSIage()
         }
         }
     }
     }

+ 41 - 12
LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift

@@ -5,6 +5,7 @@ import SwiftUI
 
 
 struct InfoDisplaySettingsView: View {
 struct InfoDisplaySettingsView: View {
     @ObservedObject var viewModel: InfoDisplaySettingsViewModel
     @ObservedObject var viewModel: InfoDisplaySettingsViewModel
+    @State private var selectedID: Int?
 
 
     var body: some View {
     var body: some View {
         Form {
         Form {
@@ -17,28 +18,56 @@ struct InfoDisplaySettingsView: View {
                 }
                 }
             }
             }
 
 
-            Section(header: Text("Information Display Settings")) {
-                ForEach(viewModel.infoSort, id: \.self) { sortedIndex in
-                    HStack {
-                        Text(viewModel.getName(for: sortedIndex))
-                        Spacer()
-                        Toggle("", isOn: Binding(
-                            get: { viewModel.infoVisible[sortedIndex] },
-                            set: { _ in
-                                viewModel.toggleVisibility(for: sortedIndex)
-                            }
-                        ))
-                        .labelsHidden()
+            Section(
+                header: Text("Information Display Settings"),
+                footer: Text("Drag to reorder. Tap a row to set its visibility and colors.")
+            ) {
+                // The list stays in edit mode so rows are always draggable; a
+                // Button (unlike NavigationLink) still receives taps in edit
+                // mode, so reordering and navigation work at the same time.
+                ForEach(viewModel.items) { item in
+                    Button {
+                        selectedID = item.id
+                    } label: {
+                        rowLabel(for: item)
                     }
                     }
+                    .buttonStyle(.plain)
                 }
                 }
                 .onMove(perform: viewModel.move)
                 .onMove(perform: viewModel.move)
             }
             }
         }
         }
         .environment(\.editMode, .constant(.active))
         .environment(\.editMode, .constant(.active))
+        .navigationDestination(isPresented: Binding(
+            get: { selectedID != nil },
+            set: { if !$0 { selectedID = nil } }
+        )) {
+            if let id = selectedID {
+                InfoRowSettingsView(item: viewModel.binding(for: id))
+            }
+        }
         .onDisappear {
         .onDisappear {
             NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
             NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
         }
         }
         .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .navigationBarTitle("Information Display Settings", displayMode: .inline)
         .navigationBarTitle("Information Display Settings", displayMode: .inline)
     }
     }
+
+    private func rowLabel(for item: InfoDisplayItem) -> some View {
+        HStack {
+            Text(item.type.name)
+            Spacer()
+            if item.type.isColorable, item.coloring.enabled {
+                Circle()
+                    .fill(Color.orange)
+                    .frame(width: 8, height: 8)
+                    .accessibilityLabel("Coloring enabled")
+            }
+            Text(item.isVisible ? "On" : "Off")
+                .foregroundStyle(.secondary)
+            Image(systemName: "chevron.right")
+                .font(.footnote.weight(.semibold))
+                .foregroundStyle(.tertiary)
+        }
+        .contentShape(Rectangle())
+    }
 }
 }

+ 18 - 15
LoopFollow/InfoDisplaySettings/InfoDisplaySettingsViewModel.swift

@@ -5,28 +5,31 @@ import Foundation
 import SwiftUI
 import SwiftUI
 
 
 class InfoDisplaySettingsViewModel: ObservableObject {
 class InfoDisplaySettingsViewModel: ObservableObject {
-    @Published var infoSort: [Int]
-    @Published var infoVisible: [Bool]
+    @Published var items: [InfoDisplayItem]
 
 
     init() {
     init() {
-        infoSort = Storage.shared.infoSort.value
-        infoVisible = Storage.shared.infoVisible.value
+        items = Storage.shared.infoDisplayItems.value
     }
     }
 
 
-    func toggleVisibility(for sortedIndex: Int) {
-        infoVisible[sortedIndex].toggle()
-        Storage.shared.infoVisible.value = infoVisible
+    func move(from source: IndexSet, to destination: Int) {
+        items.move(fromOffsets: source, toOffset: destination)
+        persist()
     }
     }
 
 
-    func move(from source: IndexSet, to destination: Int) {
-        infoSort.move(fromOffsets: source, toOffset: destination)
-        Storage.shared.infoSort.value = infoSort
+    /// A binding to a single item, looked up by id so it survives reordering.
+    /// Writes persist to Storage immediately.
+    func binding(for id: Int) -> Binding<InfoDisplayItem> {
+        Binding(
+            get: { self.items.first(where: { $0.id == id }) ?? self.items[0] },
+            set: { newValue in
+                guard let index = self.items.firstIndex(where: { $0.id == id }) else { return }
+                self.items[index] = newValue
+                self.persist()
+            }
+        )
     }
     }
 
 
-    func getName(for index: Int) -> String {
-        guard let infoType = InfoType(rawValue: index) else {
-            return "Unknown"
-        }
-        return infoType.name
+    private func persist() {
+        Storage.shared.infoDisplayItems.value = items
     }
     }
 }
 }

+ 99 - 0
LoopFollow/InfoDisplaySettings/InfoRowSettingsView.swift

@@ -0,0 +1,99 @@
+// LoopFollow
+// InfoRowSettingsView.swift
+
+import SwiftUI
+
+struct InfoRowSettingsView: View {
+    @Binding var item: InfoDisplayItem
+
+    var body: some View {
+        Form {
+            Section {
+                Toggle("Show in Info Display", isOn: $item.isVisible)
+            }
+
+            if let config = item.type.colorConfig {
+                Section(
+                    header: Text("Color"),
+                    footer: Text(colorFooter(config))
+                ) {
+                    Toggle("Enable coloring", isOn: enabledBinding(config))
+
+                    if item.coloring.enabled {
+                        thresholdRow(title: "Yellow at", value: $item.coloring.warning, default: config.defaultWarning, config: config)
+                        thresholdRow(title: "Red at", value: $item.coloring.urgent, default: config.defaultUrgent, config: config)
+
+                        if let thresholdWarning = thresholdWarning(config) {
+                            Label(thresholdWarning, systemImage: "exclamationmark.triangle.fill")
+                                .font(.footnote)
+                                .foregroundStyle(.orange)
+                        }
+                    }
+                }
+            }
+        }
+        .navigationBarTitle(item.type.name, displayMode: .inline)
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
+    }
+
+    /// Turning coloring on seeds any unset threshold, so the steppers and the
+    /// stored values always agree.
+    private func enabledBinding(_ config: InfoColorConfig) -> Binding<Bool> {
+        Binding(
+            get: { item.coloring.enabled },
+            set: { isOn in
+                if isOn {
+                    item.coloring.warning = item.coloring.warning ?? config.defaultWarning
+                    item.coloring.urgent = item.coloring.urgent ?? config.defaultUrgent
+                }
+                item.coloring.enabled = isOn
+            }
+        )
+    }
+
+    private func thresholdRow(title: String, value: Binding<Double?>, default defaultValue: Double, config: InfoColorConfig) -> some View {
+        SettingsStepperRow(
+            title: title,
+            range: config.range,
+            step: config.step,
+            value: Binding(
+                get: { value.wrappedValue ?? defaultValue },
+                set: { value.wrappedValue = $0 }
+            ),
+            format: { formatted($0, config) }
+        )
+    }
+
+    private func formatted(_ value: Double, _ config: InfoColorConfig) -> String {
+        let number = Localizer.formatToLocalizedString(
+            value,
+            maxFractionDigits: config.fractionDigits,
+            minFractionDigits: config.fractionDigits
+        )
+        return "\(number) \(config.unit)"
+    }
+
+    /// Non-blocking sanity check: red should be more severe than yellow, in the
+    /// metric's fixed direction. `nil` when consistent or a threshold is unset.
+    private func thresholdWarning(_ config: InfoColorConfig) -> String? {
+        guard item.coloring.enabled,
+              let warning = item.coloring.warning,
+              let urgent = item.coloring.urgent
+        else { return nil }
+        switch config.direction {
+        case .above:
+            return urgent < warning ? "Red should be at or above yellow." : nil
+        case .below:
+            return urgent > warning ? "Red should be at or below yellow." : nil
+        }
+    }
+
+    private func colorFooter(_ config: InfoColorConfig) -> String {
+        switch config.direction {
+        case .above:
+            return "The value turns yellow at or above the yellow level and red at or above the red level. In range it shows green. A visual cue only — it never triggers an alarm."
+        case .below:
+            return "The value turns yellow at or below the yellow level and red at or below the red level. In range it shows green. A visual cue only — it never triggers an alarm."
+        }
+    }
+}

+ 5 - 1
LoopFollow/InfoTable/InfoData.swift

@@ -7,10 +7,14 @@ class InfoData: Identifiable {
     let id: Int
     let id: Int
     let name: String
     let name: String
     var value: String
     var value: String
+    /// Raw numeric value behind `value`, when the row carries a single number.
+    /// Used for threshold-based coloring; `nil` for text or combined values.
+    var numericValue: Double?
 
 
-    init(id: Int, name: String, value: String = "") {
+    init(id: Int, name: String, value: String = "", numericValue: Double? = nil) {
         self.id = id
         self.id = id
         self.name = name
         self.name = name
         self.value = value
         self.value = value
+        self.numericValue = numericValue
     }
     }
 }
 }

+ 73 - 0
LoopFollow/InfoTable/InfoDisplayItem.swift

@@ -0,0 +1,73 @@
+// LoopFollow
+// InfoDisplayItem.swift
+
+import SwiftUI
+
+/// One configurable row in the Info Display: which info it shows, whether it is
+/// visible, and its optional threshold-based coloring. The ordered array of
+/// these replaces the old parallel `infoSort` / `infoVisible` storage.
+struct InfoDisplayItem: Codable, Equatable, Identifiable {
+    var type: InfoType
+    var isVisible: Bool
+    var coloring: InfoColoring
+
+    var id: Int { type.rawValue }
+}
+
+extension Array where Element == InfoDisplayItem {
+    func item(for type: InfoType) -> InfoDisplayItem? {
+        first { $0.type == type }
+    }
+
+    func isVisible(_ type: InfoType) -> Bool {
+        item(for: type)?.isVisible ?? false
+    }
+}
+
+/// Which direction is "concerning" for a metric. Fixed per InfoType (battery is
+/// bad when low, sensor age is bad when high) rather than user-configurable.
+enum InfoColorDirection {
+    case above // color as the value rises (e.g. SAGE, IOB)
+    case below // color as the value falls (e.g. Battery, Pump)
+}
+
+/// What threshold coloring looks like for one InfoType: the concerning
+/// direction, the unit shown next to the value, and the bounds the threshold
+/// steppers move in. Only types that expose one of these can be colored.
+struct InfoColorConfig {
+    let direction: InfoColorDirection
+    let unit: String
+    let range: ClosedRange<Double>
+    let step: Double
+    let defaultWarning: Double
+    let defaultUrgent: Double
+
+    /// Decimals to show, derived from the step (0.5 → 1, 5 → 0).
+    var fractionDigits: Int {
+        step.truncatingRemainder(dividingBy: 1) == 0 ? 0 : 1
+    }
+}
+
+/// Per-row, opt-in color thresholds. Purely visual — never triggers an alarm.
+struct InfoColoring: Codable, Equatable {
+    var enabled: Bool = false
+    var warning: Double? = nil // yellow
+    var urgent: Double? = nil // red
+
+    /// Resolves the color for a raw numeric value, given the metric's fixed
+    /// direction. Returns `nil` only when coloring is off (callers map that to
+    /// the default text color); when enabled, an in-range value reads green,
+    /// mirroring the app's BG text coloring.
+    func color(for value: Double, direction: InfoColorDirection) -> Color? {
+        guard enabled else { return nil }
+        switch direction {
+        case .above:
+            if let urgent, value >= urgent { return .red }
+            if let warning, value >= warning { return .yellow }
+        case .below:
+            if let urgent, value <= urgent { return .red }
+            if let warning, value <= warning { return .yellow }
+        }
+        return .green
+    }
+}

+ 12 - 7
LoopFollow/InfoTable/InfoManager.swift

@@ -12,14 +12,16 @@ class InfoManager: ObservableObject {
         tableData = InfoType.allCases.map { InfoData(id: $0.rawValue, name: $0.name) }
         tableData = InfoType.allCases.map { InfoData(id: $0.rawValue, name: $0.name) }
     }
     }
 
 
-    func updateInfoData(type: InfoType, value: String) {
+    func updateInfoData(type: InfoType, value: String, numericValue: Double? = nil) {
         tableData[type.rawValue].value = value
         tableData[type.rawValue].value = value
+        tableData[type.rawValue].numericValue = numericValue
         objectWillChange.send()
         objectWillChange.send()
     }
     }
 
 
     func updateInfoData(type: InfoType, value: HKQuantity) {
     func updateInfoData(type: InfoType, value: HKQuantity) {
         let formattedValue = Localizer.formatQuantity(value)
         let formattedValue = Localizer.formatQuantity(value)
-        updateInfoData(type: type, value: formattedValue)
+        let numericValue = value.doubleValue(for: Localizer.getPreferredUnit())
+        updateInfoData(type: type, value: formattedValue, numericValue: numericValue)
     }
     }
 
 
     func updateInfoData(type: InfoType, firstValue: HKQuantity, secondValue: HKQuantity, separator: InfoDataSeparator) {
     func updateInfoData(type: InfoType, firstValue: HKQuantity, secondValue: HKQuantity, separator: InfoDataSeparator) {
@@ -35,7 +37,7 @@ class InfoManager: ObservableObject {
 
 
     func updateInfoData(type: InfoType, value: Double, maxFractionDigits: Int = 1, minFractionDigits: Int = 0) {
     func updateInfoData(type: InfoType, value: Double, maxFractionDigits: Int = 1, minFractionDigits: Int = 0) {
         let formattedValue = Localizer.formatToLocalizedString(value, maxFractionDigits: maxFractionDigits, minFractionDigits: minFractionDigits)
         let formattedValue = Localizer.formatToLocalizedString(value, maxFractionDigits: maxFractionDigits, minFractionDigits: minFractionDigits)
-        updateInfoData(type: type, value: formattedValue)
+        updateInfoData(type: type, value: formattedValue, numericValue: value)
     }
     }
 
 
     func updateInfoData(type: InfoType, value: Double, enactedValue: Double, separator: InfoDataSeparator, maxFractionDigits: Int = 1, minFractionDigits: Int = 0) {
     func updateInfoData(type: InfoType, value: Double, enactedValue: Double, separator: InfoDataSeparator, maxFractionDigits: Int = 1, minFractionDigits: Int = 0) {
@@ -48,25 +50,28 @@ class InfoManager: ObservableObject {
 
 
     func updateInfoData(type: InfoType, value: Metric) {
     func updateInfoData(type: InfoType, value: Metric) {
         let formattedValue = value.formattedValue()
         let formattedValue = value.formattedValue()
-        updateInfoData(type: type, value: formattedValue)
+        updateInfoData(type: type, value: formattedValue, numericValue: value.value)
     }
     }
 
 
     func clearInfoData(type: InfoType) {
     func clearInfoData(type: InfoType) {
         tableData[type.rawValue].value = ""
         tableData[type.rawValue].value = ""
+        tableData[type.rawValue].numericValue = nil
         objectWillChange.send()
         objectWillChange.send()
     }
     }
 
 
     func clearInfoData(types: [InfoType]) {
     func clearInfoData(types: [InfoType]) {
         for type in types {
         for type in types {
             tableData[type.rawValue].value = ""
             tableData[type.rawValue].value = ""
+            tableData[type.rawValue].numericValue = nil
         }
         }
         objectWillChange.send()
         objectWillChange.send()
     }
     }
 
 
     var visibleRows: [InfoData] {
     var visibleRows: [InfoData] {
-        Storage.shared.infoSort.value
-            .filter { $0 < Storage.shared.infoVisible.value.count && Storage.shared.infoVisible.value[$0] }
-            .compactMap { index in
+        Storage.shared.infoDisplayItems.value
+            .filter { $0.isVisible }
+            .compactMap { item in
+                let index = item.type.rawValue
                 guard index < tableData.count else { return nil }
                 guard index < tableData.count else { return nil }
                 return tableData[index]
                 return tableData[index]
             }
             }

+ 14 - 4
LoopFollow/InfoTable/InfoTableView.swift

@@ -16,14 +16,24 @@ struct InfoTableView: View {
                 row(name: "Time Zone", value: tz)
                 row(name: "Time Zone", value: tz)
             }
             }
             ForEach(infoManager.visibleRows) { item in
             ForEach(infoManager.visibleRows) { item in
-                row(name: item.name, value: item.value)
+                row(name: item.name, value: item.value, valueColor: color(for: item))
             }
             }
         }
         }
         .listStyle(.plain)
         .listStyle(.plain)
         .environment(\.defaultMinListRowHeight, rowHeight)
         .environment(\.defaultMinListRowHeight, rowHeight)
     }
     }
 
 
-    private func row(name: String, value: String) -> some View {
+    /// Threshold-based color for a row's value, or nil to use the default color.
+    private func color(for item: InfoData) -> Color? {
+        guard let numericValue = item.numericValue,
+              let type = InfoType(rawValue: item.id),
+              let config = type.colorConfig
+        else { return nil }
+        return Storage.shared.infoDisplayItems.value.item(for: type)?
+            .coloring.color(for: numericValue, direction: config.direction)
+    }
+
+    private func row(name: String, value: String, valueColor: Color? = nil) -> some View {
         // Show a placeholder for any field that has no value yet,
         // Show a placeholder for any field that has no value yet,
         // so the row reads as "no data" rather than appearing empty.
         // so the row reads as "no data" rather than appearing empty.
         let displayValue = value.isEmpty ? "—" : value
         let displayValue = value.isEmpty ? "—" : value
@@ -34,14 +44,14 @@ struct InfoTableView: View {
                 Text(name)
                 Text(name)
                 Spacer()
                 Spacer()
                 Text(displayValue)
                 Text(displayValue)
-                    .foregroundStyle(.primary)
+                    .foregroundStyle(valueColor ?? .primary)
             }
             }
 
 
             // Fallback when the single line won't fit: label over value
             // Fallback when the single line won't fit: label over value
             VStack(alignment: .leading, spacing: 0) {
             VStack(alignment: .leading, spacing: 0) {
                 Text(name)
                 Text(name)
                 Text(displayValue)
                 Text(displayValue)
-                    .foregroundStyle(.primary)
+                    .foregroundStyle(valueColor ?? .primary)
                     .frame(maxWidth: .infinity, alignment: .trailing)
                     .frame(maxWidth: .infinity, alignment: .trailing)
             }
             }
         }
         }

+ 33 - 1
LoopFollow/InfoTable/InfoType.swift

@@ -3,7 +3,7 @@
 
 
 import Foundation
 import Foundation
 
 
-enum InfoType: Int, CaseIterable {
+enum InfoType: Int, CaseIterable, Codable {
     case iob, cob, basal, override, battery, pump, pumpBattery, sage, cage, recBolus, minMax, carbsToday, autosens, profile, target, isf, carbRatio, updated, tdd, iage, dbSize
     case iob, cob, basal, override, battery, pump, pumpBattery, sage, cage, recBolus, minMax, carbsToday, autosens, profile, target, isf, carbRatio, updated, tdd, iage, dbSize
 
 
     var name: String {
     var name: String {
@@ -44,4 +44,36 @@ enum InfoType: Int, CaseIterable {
     var sortOrder: Int {
     var sortOrder: Int {
         return rawValue
         return rawValue
     }
     }
+
+    /// Rows that carry a single numeric value can offer color thresholds.
+    /// Combined rows (basal, min/max), BG-unit rows (target, ISF, CR) and
+    /// pure-text rows have no config and are therefore not colorable.
+    /// Steps mirror the equivalent alarm editors, so a value that takes decimals
+    /// in an alarm takes decimals here too.
+    var colorConfig: InfoColorConfig? {
+        switch self {
+        case .iob:
+            return InfoColorConfig(direction: .above, unit: "U", range: 0 ... 20, step: 0.5, defaultWarning: 3, defaultUrgent: 5)
+        case .cob:
+            return InfoColorConfig(direction: .above, unit: "g", range: 0 ... 200, step: 1, defaultWarning: 30, defaultUrgent: 60)
+        case .battery, .pumpBattery:
+            return InfoColorConfig(direction: .below, unit: "%", range: 0 ... 100, step: 5, defaultWarning: 30, defaultUrgent: 15)
+        case .pump:
+            return InfoColorConfig(direction: .below, unit: "U", range: 0 ... 50, step: 1, defaultWarning: 20, defaultUrgent: 10)
+        case .tdd:
+            return InfoColorConfig(direction: .above, unit: "U", range: 0 ... 200, step: 1, defaultWarning: 60, defaultUrgent: 80)
+        case .recBolus:
+            return InfoColorConfig(direction: .above, unit: "U", range: 0 ... 20, step: 0.1, defaultWarning: 1, defaultUrgent: 2)
+        case .carbsToday:
+            return InfoColorConfig(direction: .above, unit: "g", range: 0 ... 500, step: 5, defaultWarning: 150, defaultUrgent: 250)
+        case .sage:
+            return InfoColorConfig(direction: .above, unit: "days", range: 0.5 ... 15, step: 0.5, defaultWarning: 9, defaultUrgent: 9.5)
+        case .cage, .iage:
+            return InfoColorConfig(direction: .above, unit: "days", range: 0.5 ... 10, step: 0.5, defaultWarning: 2.5, defaultUrgent: 3)
+        default:
+            return nil
+        }
+    }
+
+    var isColorable: Bool { colorConfig != nil }
 }
 }

+ 39 - 0
LoopFollow/Storage/Storage+Migrate.swift

@@ -60,6 +60,45 @@ extension Storage {
         UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: legacyNotificationIDs)
         UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: legacyNotificationIDs)
     }
     }
 
 
+    func migrateStep10() {
+        LogManager.shared.log(category: .general, message: "Running migrateStep10 — infoSort/infoVisible → infoDisplayItems")
+        migrateInfoDisplayItems()
+    }
+
+    /// Converts the legacy parallel `infoSort` / `infoVisible` arrays into the
+    /// unified `infoDisplayItems` store, then removes the legacy keys.
+    ///
+    /// Idempotent — does nothing once the legacy keys are gone. Called from
+    /// `migrateStep10()` and, as a safety net, unconditionally from
+    /// `synchronizeInfoTypes()`: fresh installs never persist a `migrationStep`,
+    /// so a user who customized their info display before this release would
+    /// otherwise have the step-10 guard skip their conversion.
+    func migrateInfoDisplayItems() {
+        let legacySort = StorageValue<[Int]>(key: "infoSort", defaultValue: [])
+        let legacyVisible = StorageValue<[Bool]>(key: "infoVisible", defaultValue: [])
+        guard legacySort.exists || legacyVisible.exists else { return }
+
+        let sort = legacySort.value
+        let visible = legacyVisible.value
+
+        var items: [InfoDisplayItem] = []
+        var seen = Set<Int>()
+        // Honor the saved order and per-index visibility.
+        for index in sort {
+            guard let type = InfoType(rawValue: index), seen.insert(index).inserted else { continue }
+            let isVisible = index < visible.count ? visible[index] : type.defaultVisible
+            items.append(InfoDisplayItem(type: type, isVisible: isVisible, coloring: InfoColoring()))
+        }
+        // Append any InfoType missing from the saved order (new cases, gaps).
+        for type in InfoType.allCases where seen.insert(type.rawValue).inserted {
+            items.append(InfoDisplayItem(type: type, isVisible: type.defaultVisible, coloring: InfoColoring()))
+        }
+
+        Storage.shared.infoDisplayItems.value = items
+        legacySort.remove()
+        legacyVisible.remove()
+    }
+
     func migrateStep9() {
     func migrateStep9() {
         // Default for debugLogLevel changed from false to true so users ship useful
         // Default for debugLogLevel changed from false to true so users ship useful
         // logs when they report a problem. Force-enable for existing users.
         // logs when they report a problem. Force-enable for existing users.

+ 7 - 3
LoopFollow/Storage/Storage.swift

@@ -203,8 +203,12 @@ class Storage {
     var units = StorageValue<String>(key: "units", defaultValue: "mg/dL")
     var units = StorageValue<String>(key: "units", defaultValue: "mg/dL")
     var hasConfiguredUnits = StorageValue<Bool>(key: "hasConfiguredUnits", defaultValue: false)
     var hasConfiguredUnits = StorageValue<Bool>(key: "hasConfiguredUnits", defaultValue: false)
 
 
-    var infoSort = StorageValue<[Int]>(key: "infoSort", defaultValue: InfoType.allCases.map(\.sortOrder))
-    var infoVisible = StorageValue<[Bool]>(key: "infoVisible", defaultValue: InfoType.allCases.map(\.defaultVisible))
+    var infoDisplayItems = StorageValue<[InfoDisplayItem]>(
+        key: "infoDisplayItems",
+        defaultValue: InfoType.allCases.map {
+            InfoDisplayItem(type: $0, isVisible: $0.defaultVisible, coloring: InfoColoring())
+        }
+    )
 
 
     var url = StorageValue<String>(key: "url", defaultValue: "")
     var url = StorageValue<String>(key: "url", defaultValue: "")
     var device = StorageValue<String>(key: "device", defaultValue: "")
     var device = StorageValue<String>(key: "device", defaultValue: "")
@@ -215,7 +219,7 @@ class Storage {
     // When adding a new migration step in `runMigrationsIfNeeded()`, bump this default
     // When adding a new migration step in `runMigrationsIfNeeded()`, bump this default
     // to the new latest step number so fresh installs skip all migrations. Other defaults
     // to the new latest step number so fresh installs skip all migrations. Other defaults
     // in this file must reflect the post-migration final state for a fresh install.
     // in this file must reflect the post-migration final state for a fresh install.
-    var migrationStep = StorageValue<Int>(key: "migrationStep", defaultValue: 9)
+    var migrationStep = StorageValue<Int>(key: "migrationStep", defaultValue: 10)
 
 
     var persistentNotification = StorageValue<Bool>(key: "persistentNotification", defaultValue: false)
     var persistentNotification = StorageValue<Bool>(key: "persistentNotification", defaultValue: false)
     var persistentNotificationLastBGTime = StorageValue<Date>(key: "persistentNotificationLastBGTime", defaultValue: .distantPast)
     var persistentNotificationLastBGTime = StorageValue<Date>(key: "persistentNotificationLastBGTime", defaultValue: .distantPast)

+ 17 - 25
LoopFollow/ViewControllers/MainViewController.swift

@@ -658,6 +658,11 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate {
             Storage.shared.migrateStep9()
             Storage.shared.migrateStep9()
             Storage.shared.migrationStep.value = 9
             Storage.shared.migrationStep.value = 9
         }
         }
+
+        if Storage.shared.migrationStep.value < 10 {
+            Storage.shared.migrateStep10()
+            Storage.shared.migrationStep.value = 10
+        }
     }
     }
 
 
     @objc func appDidBecomeActive() {
     @objc func appDidBecomeActive() {
@@ -994,36 +999,23 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate {
     }
     }
 
 
     private func synchronizeInfoTypes() {
     private func synchronizeInfoTypes() {
-        var sortArray = Storage.shared.infoSort.value
-        var visibleArray = Storage.shared.infoVisible.value
-
-        // Current valid indices based on InfoType
-        let currentValidIndices = InfoType.allCases.map { $0.rawValue }
-
-        // Add missing indices to sortArray
-        for index in currentValidIndices {
-            if !sortArray.contains(index) {
-                sortArray.append(index)
-            }
-        }
+        // Safety net: fold any leftover legacy infoSort/infoVisible into
+        // infoDisplayItems for users whose migrationStep predates that key.
+        Storage.shared.migrateInfoDisplayItems()
 
 
-        // Remove deprecated indices
-        sortArray = sortArray.filter { currentValidIndices.contains($0) }
+        var items = Storage.shared.infoDisplayItems.value
 
 
-        // Ensure visibleArray is updated with new entries
-        if visibleArray.count < currentValidIndices.count {
-            for i in visibleArray.count ..< currentValidIndices.count {
-                visibleArray.append(InfoType(rawValue: i)?.defaultVisible ?? false)
-            }
-        }
+        // Drop items whose InfoType no longer exists.
+        let validTypes = Set(InfoType.allCases)
+        items.removeAll { !validTypes.contains($0.type) }
 
 
-        // Trim excess elements if there are more than needed
-        if visibleArray.count > currentValidIndices.count {
-            visibleArray = Array(visibleArray.prefix(currentValidIndices.count))
+        // Append any newly added InfoType.
+        let present = Set(items.map(\.type))
+        for type in InfoType.allCases where !present.contains(type) {
+            items.append(InfoDisplayItem(type: type, isVisible: type.defaultVisible, coloring: InfoColoring()))
         }
         }
 
 
-        Storage.shared.infoSort.value = sortArray
-        Storage.shared.infoVisible.value = visibleArray
+        Storage.shared.infoDisplayItems.value = items
     }
     }
 
 
     // MARK: - First Time Setup
     // MARK: - First Time Setup

+ 71 - 0
Tests/InfoColoringTests.swift

@@ -0,0 +1,71 @@
+// LoopFollow
+// InfoColoringTests.swift
+
+@testable import LoopFollow
+import SwiftUI
+import Testing
+
+struct InfoColoringTests {
+    @Test("disabled coloring returns nil regardless of value")
+    func disabledReturnsNil() {
+        let coloring = InfoColoring(enabled: false, warning: 30, urgent: 15)
+        #expect(coloring.color(for: 5, direction: .below) == nil)
+    }
+
+    @Test("below: battery warn 30 / urgent 15, in-range is green")
+    func belowDirection() {
+        let coloring = InfoColoring(enabled: true, warning: 30, urgent: 15)
+        #expect(coloring.color(for: 40, direction: .below) == .green)
+        #expect(coloring.color(for: 30, direction: .below) == .yellow)
+        #expect(coloring.color(for: 25, direction: .below) == .yellow)
+        #expect(coloring.color(for: 15, direction: .below) == .red)
+        #expect(coloring.color(for: 10, direction: .below) == .red)
+    }
+
+    @Test("above: SAGE warn 8 / urgent 9.5, in-range is green")
+    func aboveDirection() {
+        let coloring = InfoColoring(enabled: true, warning: 8, urgent: 9.5)
+        #expect(coloring.color(for: 6, direction: .above) == .green)
+        #expect(coloring.color(for: 8, direction: .above) == .yellow)
+        #expect(coloring.color(for: 8.5, direction: .above) == .yellow)
+        #expect(coloring.color(for: 9.5, direction: .above) == .red)
+        #expect(coloring.color(for: 12, direction: .above) == .red)
+    }
+
+    @Test("nil thresholds are green when enabled")
+    func nilThresholds() {
+        let coloring = InfoColoring(enabled: true, warning: nil, urgent: nil)
+        #expect(coloring.color(for: 999, direction: .above) == .green)
+    }
+
+    @Test("only urgent set still colors red, otherwise green")
+    func onlyUrgent() {
+        let coloring = InfoColoring(enabled: true, warning: nil, urgent: 15)
+        #expect(coloring.color(for: 20, direction: .below) == .green)
+        #expect(coloring.color(for: 15, direction: .below) == .red)
+    }
+
+    @Test("pump reservoir colors on the way down, and a capped 50+ reads green")
+    func pumpReservoir() throws {
+        let config = try #require(InfoType.pump.colorConfig)
+        let coloring = InfoColoring(enabled: true, warning: config.defaultWarning, urgent: config.defaultUrgent)
+        #expect(coloring.color(for: 50, direction: config.direction) == .green)
+        #expect(coloring.color(for: 20, direction: config.direction) == .yellow)
+        #expect(coloring.color(for: 10, direction: config.direction) == .red)
+    }
+
+    @Test("thresholds that need decimals get a fractional step")
+    func fractionalSteps() {
+        #expect(InfoType.recBolus.colorConfig?.step == 0.1)
+        #expect(InfoType.iob.colorConfig?.step == 0.5)
+        #expect(InfoType.sage.colorConfig?.fractionDigits == 1)
+        #expect(InfoType.cob.colorConfig?.fractionDigits == 0)
+    }
+
+    @Test("non-numeric rows are not colorable")
+    func notColorable() {
+        #expect(InfoType.basal.isColorable == false)
+        #expect(InfoType.minMax.isColorable == false)
+        #expect(InfoType.pump.isColorable)
+    }
+}