Jonas Björkert пре 1 година
родитељ
комит
f06f8f01f8

+ 12 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -165,6 +165,9 @@
 		DDCC3A4D2DDBB77C006F1C10 /* BatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A4C2DDBB77C006F1C10 /* BatteryAlarmEditor.swift */; };
 		DDCC3A4F2DDC5B54006F1C10 /* BatteryDropCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */; };
 		DDCC3A542DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A532DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift */; };
+		DDCC3A562DDC9617006F1C10 /* MissedBolusCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A552DDC9617006F1C10 /* MissedBolusCondition.swift */; };
+		DDCC3A582DDC9655006F1C10 /* MissedBolusAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */; };
+		DDCC3A5A2DDC988F006F1C10 /* CarbSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A592DDC988F006F1C10 /* CarbSample.swift */; };
 		DDCF979424C0D380002C9752 /* UIViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979324C0D380002C9752 /* UIViewExtension.swift */; };
 		DDCF979624C1443C002C9752 /* GeneralSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979524C1443C002C9752 /* GeneralSettingsViewController.swift */; };
 		DDCF979824C1489C002C9752 /* GraphSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979724C1489C002C9752 /* GraphSettingsViewController.swift */; };
@@ -524,6 +527,9 @@
 		DDCC3A4C2DDBB77C006F1C10 /* BatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryAlarmEditor.swift; sourceTree = "<group>"; };
 		DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryDropCondition.swift; sourceTree = "<group>"; };
 		DDCC3A532DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryDropAlarmEditor.swift; sourceTree = "<group>"; };
+		DDCC3A552DDC9617006F1C10 /* MissedBolusCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedBolusCondition.swift; sourceTree = "<group>"; };
+		DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedBolusAlarmEditor.swift; sourceTree = "<group>"; };
+		DDCC3A592DDC988F006F1C10 /* CarbSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbSample.swift; sourceTree = "<group>"; };
 		DDCF979324C0D380002C9752 /* UIViewExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewExtension.swift; sourceTree = "<group>"; };
 		DDCF979524C1443C002C9752 /* GeneralSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsViewController.swift; sourceTree = "<group>"; };
 		DDCF979724C1489C002C9752 /* GraphSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphSettingsViewController.swift; sourceTree = "<group>"; };
@@ -760,6 +766,7 @@
 		DD02475A2DB2E8CE00FCADF6 /* AlarmCondition */ = {
 			isa = PBXGroup;
 			children = (
+				DDCC3A552DDC9617006F1C10 /* MissedBolusCondition.swift */,
 				DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */,
 				DDCC3A4A2DDBB5E4006F1C10 /* BatteryCondition.swift */,
 				DDB9FC7A2DDB573F00EFAA76 /* IOBCondition.swift */,
@@ -1036,6 +1043,7 @@
 		DDC7E53F2DBD8A1600EB1127 /* Editors */ = {
 			isa = PBXGroup;
 			children = (
+				DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */,
 				DDCC3A532DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift */,
 				DDCC3A4C2DDBB77C006F1C10 /* BatteryAlarmEditor.swift */,
 				DDB9FC7C2DDB575300EFAA76 /* IOBAlarmEditor.swift */,
@@ -1085,6 +1093,7 @@
 		DDCC3A502DDC5BD4006F1C10 /* DataStructs */ = {
 			isa = PBXGroup;
 			children = (
+				DDCC3A592DDC988F006F1C10 /* CarbSample.swift */,
 				DDB9FC7E2DDB584500EFAA76 /* BolusEntry.swift */,
 				DD5DA27B2DC930D6003D44FC /* GlucoseValue.swift */,
 			);
@@ -1830,6 +1839,7 @@
 				DDF2C0122BEFB733007A20E6 /* AppVersionManager.swift in Sources */,
 				DDB9FC7B2DDB573F00EFAA76 /* IOBCondition.swift in Sources */,
 				DD7E19862ACDA59700DBD158 /* BGCheck.swift in Sources */,
+				DDCC3A5A2DDC988F006F1C10 /* CarbSample.swift in Sources */,
 				DD0650F12DCE9A9E004D3B41 /* MissedReadingCondition.swift in Sources */,
 				DDC6CA4B2DD8E4960060EE25 /* PumpVolumeAlarmEditor.swift in Sources */,
 				DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */,
@@ -1841,6 +1851,7 @@
 				DD0650F92DCFE7BE004D3B41 /* FastDropAlarmEditor.swift in Sources */,
 				DD5817172D2710E90041FB98 /* BLEDeviceSelectionView.swift in Sources */,
 				FC16A97B249966A3003D6245 /* AlarmSound.swift in Sources */,
+				DDCC3A562DDC9617006F1C10 /* MissedBolusCondition.swift in Sources */,
 				DDBE3ABD2CB5A961006B37DC /* OverrideView.swift in Sources */,
 				DDB0AF522BB1A8BE00AFA48B /* BuildDetails.swift in Sources */,
 				DDA9ACAA2D6A6B8300E6F1A9 /* ContactIncludeOption.swift in Sources */,
@@ -1996,6 +2007,7 @@
 				DD493ADD2ACF21E0009A6922 /* Basals.swift in Sources */,
 				FC16A98124996C07003D6245 /* DateTime.swift in Sources */,
 				FC3CAB022493B6220068A152 /* BackgroundTaskAudio.swift in Sources */,
+				DDCC3A582DDC9655006F1C10 /* MissedBolusAlarmEditor.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};

+ 66 - 0
LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift

@@ -0,0 +1,66 @@
+// LoopFollow
+// MissedBolusCondition.swift
+// Created by Jonas Björkert on 2025-05-20.
+
+import Foundation
+
+/// Fires when a carb entry is logged but **no** qualifying bolus is given
+/// within the user-defined “delay” (after allowing for a pre-bolus window).
+/// • Ignores small-carb treatments, tiny boluses, and low-BG scenarios.
+/// • Triggers once per carb entry.
+struct MissedBolusCondition: AlarmCondition {
+    static let type: AlarmType = .missedBolus
+    init() {}
+
+    func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool {
+        // ────────────────────────────────
+        // 0. pull user settings
+        // ────────────────────────────────
+        guard
+            let delayMin = alarm.monitoringWindow, delayMin > 0,
+            let prebolusMin = alarm.predictiveMinutes,
+            let minBolusU = alarm.delta, // ignore bolus ≤
+            let minCarbGr = alarm.threshold, // ignore carbs ≤
+            let minBG = alarm.aboveBG // ignore BG ≤
+        else { return false }
+
+        // ────────────────────────────────
+        // 1. get most-recent carb entry
+        // ────────────────────────────────
+        guard let carb = data.recentCarbs.last else { return false }
+
+        //  – must be at least `delayMin` old, but not older than 60 min
+        guard carb.date > now.addingTimeInterval(-3600),
+              carb.date < now.addingTimeInterval(-Double(delayMin) * 60)
+        else { return false }
+
+        //  – ignore tiny carbs
+        guard carb.grams > minCarbGr else { return false }
+
+        //  – ignore if BG is low
+        if let latestBG = data.bgReadings.last,
+           Double(latestBG.sgv) <= minBG { return false }
+
+        // ────────────────────────────────
+        // 2. already alerted for this carb?
+        // ────────────────────────────────
+        if let lastFired = Storage.shared.lastMissedBolusNotified.value,
+           carb.date <= lastFired { return false }
+
+        // ────────────────────────────────
+        // 3. look for a valid bolus
+        // ────────────────────────────────
+        let windowStart = carb.date.addingTimeInterval(-Double(prebolusMin) * 60)
+
+        let hasBolus = data.recentBoluses.contains { bolus in
+            bolus.date >= windowStart && bolus.units > minBolusU
+        }
+        guard !hasBolus else { return false }
+
+        // ────────────────────────────────
+        // 4. trigger!
+        // ────────────────────────────────
+        Storage.shared.lastMissedBolusNotified.value = carb.date
+        return true
+    }
+}

+ 1 - 4
LoopFollow/Alarm/AlarmData.swift

@@ -22,8 +22,5 @@ struct AlarmData: Codable {
     let recentBoluses: [BolusEntry]
     let latestBattery: Double?
     let batteryHistory: [DataStructs.batteryStruct]
+    let recentCarbs: [CarbSample]
 }
-
-/*
- //    let latestCarbs: [CarbEntry]
- */

+ 5 - 4
LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift

@@ -78,10 +78,11 @@ struct AlarmEditor: View {
             BatteryAlarmEditor(alarm: $alarm)
         case .batteryDrop:
             BatteryDropAlarmEditor(alarm: $alarm)
-        /* TODO: add other condition types here */
-        default:
-            Text("No editor for \(alarm.type.rawValue)")
-                .padding()
+        case .missedBolus:
+            MissedBolusAlarmEditor(alarm: $alarm)
+        case .bolus:
+            // TODO:
+            MissedBolusAlarmEditor(alarm: $alarm)
         }
     }
 }

+ 86 - 0
LoopFollow/Alarm/AlarmEditing/Editors/MissedBolusAlarmEditor.swift

@@ -0,0 +1,86 @@
+// LoopFollow
+// MissedBolusAlarmEditor.swift
+// Created by Jonas Björkert on 2025-05-20.
+
+import SwiftUI
+
+struct MissedBolusAlarmEditor: View {
+    @Binding var alarm: Alarm
+
+    var body: some View {
+        Form {
+            InfoBanner(
+                text: "Alerts when carbs are logged but no bolus is delivered " +
+                    "within the delay below.  Allows small-carb / treatment " +
+                    "exclusions and pre-bolus detection.",
+                alarmType: alarm.type
+            )
+
+            AlarmGeneralSection(alarm: $alarm)
+
+            AlarmStepperSection(
+                header: "Delay",
+                footer: "Minutes to wait after the carb entry before checking " +
+                    "for a bolus.",
+                title: "Minutes",
+                range: 5 ... 60,
+                step: 5,
+                value: Binding(
+                    get: { Double(alarm.monitoringWindow ?? 15) },
+                    set: { alarm.monitoringWindow = Int($0) }
+                )
+            )
+
+            AlarmStepperSection(
+                header: "Pre-bolus",
+                footer: "Count boluses given up to this many minutes *before* " +
+                    "the carb entry as valid.",
+                title: "Minutes",
+                range: 0 ... 45,
+                step: 5,
+                value: Binding(
+                    get: { Double(alarm.predictiveMinutes ?? 15) },
+                    set: { alarm.predictiveMinutes = Int($0) }
+                )
+            )
+
+            AlarmStepperSection(
+                header: "Ignore small boluses",
+                footer: "Boluses ≤ this size are ignored.",
+                title: "Units",
+                range: 0.05 ... 2,
+                step: 0.05,
+                value: Binding(
+                    get: { alarm.delta ?? 0.1 },
+                    set: { alarm.delta = $0 }
+                )
+            )
+
+            AlarmStepperSection(
+                header: "Ignore small carbs",
+                footer: "Carb entries ≤ this amount will not trigger the alarm.",
+                title: "Grams",
+                range: 0 ... 15,
+                step: 1,
+                value: Binding(
+                    get: { alarm.threshold ?? 4 },
+                    set: { alarm.threshold = $0 }
+                )
+            )
+
+            AlarmBGLimitSection(
+                header: "Ignore low BG",
+                footer: "Only alert if the current BG is *above* this value.",
+                toggleText: "Use BG Limit",
+                pickerTitle: "Alert when BG ≥",
+                range: 40 ... 140,
+                value: $alarm.aboveBG
+            )
+
+            AlarmActiveSection(alarm: $alarm)
+            AlarmAudioSection(alarm: $alarm)
+            AlarmSnoozeSection(alarm: $alarm, range: 5 ... 60, step: 5)
+        }
+        .navigationTitle(alarm.type.rawValue)
+    }
+}

+ 0 - 1
LoopFollow/Alarm/AlarmManager.swift

@@ -33,7 +33,6 @@ class AlarmManager {
             IOBCondition.self,
             BatteryCondition.self,
             BatteryDropCondition.self,
-            // TODO: add other condition types here
         ]
     ) {
         var dict = [AlarmType: AlarmCondition]()

+ 10 - 0
LoopFollow/Alarm/DataStructs/CarbSample.swift

@@ -0,0 +1,10 @@
+// LoopFollow
+// CarbSample.swift
+// Created by Jonas Björkert on 2025-05-20.
+
+import Foundation
+
+public struct CarbSample: Codable {
+    public let grams: Double
+    public let date: Date
+}

+ 1 - 0
LoopFollow/Storage/Storage.swift

@@ -55,6 +55,7 @@ class Storage {
     var lastTempTargetEndNotified = StorageValue<TimeInterval?>(key: "lastTempTargetEndNotified", defaultValue: nil)
     var lastRecBolusNotified = StorageValue<Double?>(key: "lastRecBolusNotified", defaultValue: nil)
     var lastCOBNotified = StorageValue<Double?>(key: "lastCOBNotified", defaultValue: nil)
+    var lastMissedBolusNotified = StorageValue<Date?>(key: "lastMissedBolusNotified", defaultValue: nil)
 
     static let shared = Storage()
     private init() {}

+ 3 - 1
LoopFollow/Task/AlarmTask.swift

@@ -26,6 +26,7 @@ extension MainViewController {
             let latestPumpVol = self.latestPumpVolume
             let bolusEntries = self.bolusData.map { BolusEntry(units: $0.value, date: Date(timeIntervalSince1970: $0.date)) }
             let latestBattery = UserDefaultsRepository.deviceBatteryLevel.value
+            let recentCarbs: [CarbSample] = self.carbData.map { CarbSample(grams: $0.value, date: Date(timeIntervalSince1970: $0.date)) }
 
             let alarmData = AlarmData(
                 bgReadings: self.bgData
@@ -48,7 +49,8 @@ extension MainViewController {
                 IOB: self.latestIOB?.value,
                 recentBoluses: bolusEntries,
                 latestBattery: latestBattery,
-                batteryHistory: self.deviceBatteryData
+                batteryHistory: self.deviceBatteryData,
+                recentCarbs: recentCarbs
             )
 
             let finalAlarmData: AlarmData