From d6a988e640d38ea791196926b774fa4dd91bbc6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 21 Mar 2026 23:50:56 +0100 Subject: [PATCH 1/6] Add Future Carbs Alert Notify when a future-dated carb entry's scheduled time arrives, serving as a reminder to start eating in pre-bolus scenarios. Tracks future carb entries across alarm ticks using persistent storage, with configurable max lookahead window (default 45 min) to filter out fat/protein entries and minimum carb threshold (default 5g). --- LoopFollow.xcodeproj/project.pbxproj | 12 +++ LoopFollow/Alarm/Alarm.swift | 9 +- .../AlarmCondition/FutureCarbsCondition.swift | 97 +++++++++++++++++++ .../Alarm/AlarmEditing/AlarmEditor.swift | 1 + .../Editors/FutureCarbsAlarmEditor.swift | 46 +++++++++ LoopFollow/Alarm/AlarmManager.swift | 1 + .../Alarm/AlarmType/AlarmType+Snooze.swift | 2 +- .../AlarmType/AlarmType+canAcknowledge.swift | 2 +- LoopFollow/Alarm/AlarmType/AlarmType.swift | 1 + .../Alarm/DataStructs/PendingFutureCarb.swift | 17 ++++ .../ImportExport/AlarmSelectionView.swift | 2 + LoopFollow/Storage/Storage.swift | 1 + 12 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift create mode 100644 LoopFollow/Alarm/AlarmEditing/Editors/FutureCarbsAlarmEditor.swift create mode 100644 LoopFollow/Alarm/DataStructs/PendingFutureCarb.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 5bd76cc2a..43e169c63 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -208,6 +208,9 @@ 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 */; }; + F19449721F3B792730A0F4FD /* PendingFutureCarb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */; }; + 2D8068C66833EEAED7B4BEB8 /* FutureCarbsCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */; }; + ACE7F6DE0D065BEB52CDC0DB /* FutureCarbsAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */; }; DDCF979424C0D380002C9752 /* UIViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979324C0D380002C9752 /* UIViewExtension.swift */; }; DDCF9A802D85FD0B004DF4DD /* Alarm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */; }; DDCF9A822D85FD15004DF4DD /* AlarmType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A812D85FD14004DF4DD /* AlarmType.swift */; }; @@ -613,6 +616,9 @@ DDCC3A552DDC9617006F1C10 /* MissedBolusCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedBolusCondition.swift; sourceTree = ""; }; DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedBolusAlarmEditor.swift; sourceTree = ""; }; DDCC3A592DDC988F006F1C10 /* CarbSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbSample.swift; sourceTree = ""; }; + 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingFutureCarb.swift; sourceTree = ""; }; + 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsCondition.swift; sourceTree = ""; }; + B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsAlarmEditor.swift; sourceTree = ""; }; DDCC3ABF2DDE10B0006F1C10 /* Testing.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Testing.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/Testing.framework; sourceTree = DEVELOPER_DIR; }; DDCC3AD62DDE1790006F1C10 /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DDCF979324C0D380002C9752 /* UIViewExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewExtension.swift; sourceTree = ""; }; @@ -897,6 +903,7 @@ isa = PBXGroup; children = ( DDCC3A552DDC9617006F1C10 /* MissedBolusCondition.swift */, + 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */, DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */, DDCC3A4A2DDBB5E4006F1C10 /* BatteryCondition.swift */, DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */, @@ -1143,6 +1150,7 @@ isa = PBXGroup; children = ( DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */, + B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */, DDCC3A532DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift */, DDCC3A4C2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift */, DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */, @@ -1194,6 +1202,7 @@ isa = PBXGroup; children = ( DDCC3A592DDC988F006F1C10 /* CarbSample.swift */, + 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */, DDB9FC7E2DDB584500EFAA76 /* BolusEntry.swift */, DD5DA27B2DC930D6003D44FC /* GlucoseValue.swift */, ); @@ -1993,6 +2002,9 @@ DDB9FC7B2DDB573F00EFAA76 /* IOBCondition.swift in Sources */, DD7E19862ACDA59700DBD158 /* BGCheck.swift in Sources */, DDCC3A5A2DDC988F006F1C10 /* CarbSample.swift in Sources */, + F19449721F3B792730A0F4FD /* PendingFutureCarb.swift in Sources */, + 2D8068C66833EEAED7B4BEB8 /* FutureCarbsCondition.swift in Sources */, + ACE7F6DE0D065BEB52CDC0DB /* FutureCarbsAlarmEditor.swift in Sources */, DD0650F12DCE9A9E004D3B41 /* MissedReadingCondition.swift in Sources */, DDC6CA4B2DD8E4960060EE25 /* PumpVolumeAlarmEditor.swift in Sources */, 6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */, diff --git a/LoopFollow/Alarm/Alarm.swift b/LoopFollow/Alarm/Alarm.swift index 6b6b37f6d..ab6f7964e 100644 --- a/LoopFollow/Alarm/Alarm.swift +++ b/LoopFollow/Alarm/Alarm.swift @@ -309,6 +309,11 @@ struct Alarm: Identifiable, Codable, Equatable { predictiveMinutes = 15 delta = 0.1 threshold = 4 + case .futureCarbs: + soundFile = .alertToneRingtone1 + threshold = 45 // max lookahead minutes + delta = 5 // min grams + repeatSoundOption = .never case .sensorChange: soundFile = .wakeUpWillYou threshold = 12 @@ -364,7 +369,7 @@ extension AlarmType { switch self { case .low, .high, .fastDrop, .fastRise, .missedReading, .temporary: return .glucose - case .iob, .cob, .missedBolus, .recBolus: + case .iob, .cob, .missedBolus, .futureCarbs, .recBolus: return .insulin case .battery, .batteryDrop, .pump, .pumpBattery, .pumpChange, .sensorChange, .notLooping, .buildExpire: @@ -384,6 +389,7 @@ extension AlarmType { case .iob: return "syringe" case .cob: return "fork.knife" case .missedBolus: return "exclamationmark.arrow.triangle.2.circlepath" + case .futureCarbs: return "clock.arrow.circlepath" case .recBolus: return "bolt.horizontal" case .battery: return "battery.25" case .batteryDrop: return "battery.100.bolt" @@ -411,6 +417,7 @@ extension AlarmType { case .iob: return "High insulin-on-board." case .cob: return "High carbs-on-board." case .missedBolus: return "Carbs without bolus." + case .futureCarbs: return "Reminder when future carbs are due." case .recBolus: return "Recommended bolus issued." case .battery: return "Phone battery low." case .batteryDrop: return "Battery drops quickly." diff --git a/LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift b/LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift new file mode 100644 index 000000000..8fa0a7a27 --- /dev/null +++ b/LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift @@ -0,0 +1,97 @@ +// LoopFollow +// FutureCarbsCondition.swift + +import Foundation + +/// Fires once when a future-dated carb entry's scheduled time arrives. +/// +/// **How it works:** +/// 1. Each alarm tick scans `recentCarbs` for entries whose `date` is in the future +/// (within a configurable max lookahead window). New ones are added to a persistent +/// "pending" list. +/// 2. When a pending entry's `carbDate` passes (i.e. `carbDate <= now`), verify the +/// carb still exists in `recentCarbs`. If so, fire the alarm. If the carb was +/// deleted, silently remove it. +/// 3. Stale entries (observed > 2 hours ago) are cleaned up automatically. +struct FutureCarbsCondition: AlarmCondition { + static let type: AlarmType = .futureCarbs + init() {} + + func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool { + // ──────────────────────────────── + // 0. Pull settings + // ──────────────────────────────── + let maxLookaheadMin = alarm.threshold ?? 45 // max lookahead in minutes + let minGrams = alarm.delta ?? 5 // ignore carbs below this + + let nowTI = now.timeIntervalSince1970 + let maxLookaheadSec = maxLookaheadMin * 60 + + var pending = Storage.shared.pendingFutureCarbs.value + let tolerance: TimeInterval = 5 // seconds, for matching carb entries + + // ──────────────────────────────── + // 1. Scan for new future carbs + // ──────────────────────────────── + for carb in data.recentCarbs { + let carbTI = carb.date.timeIntervalSince1970 + + // Must be in the future and within the lookahead window + guard carbTI > nowTI, + carbTI - nowTI <= maxLookaheadSec, + carb.grams >= minGrams + else { continue } + + // Already tracked? + let alreadyTracked = pending.contains { entry in + abs(entry.carbDate - carbTI) < tolerance && entry.grams == carb.grams + } + if !alreadyTracked { + pending.append(PendingFutureCarb( + carbDate: carbTI, + grams: carb.grams, + observedAt: nowTI + )) + } + } + + // ──────────────────────────────── + // 2. Check if any pending entry is due + // ──────────────────────────────── + var fired = false + + pending.removeAll { entry in + // Cleanup stale entries (observed > 2 hours ago) + if nowTI - entry.observedAt > 7200 { + return true + } + + // Not yet due + guard entry.carbDate <= nowTI else { return false } + + // Due — verify carb still exists in recentCarbs + let stillExists = data.recentCarbs.contains { carb in + abs(carb.date.timeIntervalSince1970 - entry.carbDate) < tolerance + && carb.grams == entry.grams + } + + if stillExists, !fired { + fired = true + return true // remove from pending after firing + } + + // Carb was deleted or we already fired this tick — remove silently + if !stillExists { + return true + } + + return false + } + + // ──────────────────────────────── + // 3. Persist and return + // ──────────────────────────────── + Storage.shared.pendingFutureCarbs.value = pending + return fired + } +} diff --git a/LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift index 6ca26d576..e8ff4aff5 100644 --- a/LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift +++ b/LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift @@ -82,6 +82,7 @@ struct AlarmEditor: View { case .battery: PhoneBatteryAlarmEditor(alarm: $alarm) case .batteryDrop: BatteryDropAlarmEditor(alarm: $alarm) case .missedBolus: MissedBolusAlarmEditor(alarm: $alarm) + case .futureCarbs: FutureCarbsAlarmEditor(alarm: $alarm) } } } diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/FutureCarbsAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/FutureCarbsAlarmEditor.swift new file mode 100644 index 000000000..0df1e2177 --- /dev/null +++ b/LoopFollow/Alarm/AlarmEditing/Editors/FutureCarbsAlarmEditor.swift @@ -0,0 +1,46 @@ +// LoopFollow +// FutureCarbsAlarmEditor.swift + +import SwiftUI + +struct FutureCarbsAlarmEditor: View { + @Binding var alarm: Alarm + + var body: some View { + Group { + InfoBanner( + text: "Alerts when a future-dated carb entry's scheduled time arrives — " + + "a reminder to start eating. Use the max lookahead to ignore " + + "fat/protein entries that are typically scheduled further ahead.", + alarmType: alarm.type + ) + + AlarmGeneralSection(alarm: $alarm) + + AlarmStepperSection( + header: "Max Lookahead", + footer: "Only track carb entries scheduled up to this many minutes " + + "in the future. Entries beyond this window are ignored.", + title: "Lookahead", + range: 5 ... 120, + step: 5, + unitLabel: "min", + value: $alarm.threshold + ) + + AlarmStepperSection( + header: "Minimum Carbs", + footer: "Ignore carb entries below this amount.", + title: "At or Above", + range: 0 ... 50, + step: 1, + unitLabel: "g", + value: $alarm.delta + ) + + AlarmActiveSection(alarm: $alarm) + AlarmAudioSection(alarm: $alarm) + AlarmSnoozeSection(alarm: $alarm) + } + } +} diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index 436535ea6..3f5aa84ec 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -33,6 +33,7 @@ class AlarmManager { IOBCondition.self, BatteryCondition.self, BatteryDropCondition.self, + FutureCarbsCondition.self, ] ) { var dict = [AlarmType: AlarmCondition]() diff --git a/LoopFollow/Alarm/AlarmType/AlarmType+Snooze.swift b/LoopFollow/Alarm/AlarmType/AlarmType+Snooze.swift index e242226cd..134e1fb5b 100644 --- a/LoopFollow/Alarm/AlarmType/AlarmType+Snooze.swift +++ b/LoopFollow/Alarm/AlarmType/AlarmType+Snooze.swift @@ -11,7 +11,7 @@ extension AlarmType { return .day case .low, .high, .fastDrop, .fastRise, .missedReading, .notLooping, .missedBolus, - .recBolus, + .futureCarbs, .recBolus, .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd: return .minute diff --git a/LoopFollow/Alarm/AlarmType/AlarmType+canAcknowledge.swift b/LoopFollow/Alarm/AlarmType/AlarmType+canAcknowledge.swift index 151dd3914..9f5f3b5d1 100644 --- a/LoopFollow/Alarm/AlarmType/AlarmType+canAcknowledge.swift +++ b/LoopFollow/Alarm/AlarmType/AlarmType+canAcknowledge.swift @@ -8,7 +8,7 @@ extension AlarmType { var canAcknowledge: Bool { switch self { // These are alarms that typically has a "memory", they will only alarm once and acknowledge them is fine - case .low, .high, .fastDrop, .fastRise, .temporary, .cob, .missedBolus, .recBolus, .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd: + case .low, .high, .fastDrop, .fastRise, .temporary, .cob, .missedBolus, .futureCarbs, .recBolus, .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd: return true // These are alarms without memory, if they only are acknowledged - they would alarm again immediately case diff --git a/LoopFollow/Alarm/AlarmType/AlarmType.swift b/LoopFollow/Alarm/AlarmType/AlarmType.swift index 7df7780a4..11a51885e 100644 --- a/LoopFollow/Alarm/AlarmType/AlarmType.swift +++ b/LoopFollow/Alarm/AlarmType/AlarmType.swift @@ -16,6 +16,7 @@ enum AlarmType: String, CaseIterable, Codable { case missedReading = "Missed Reading Alert" case notLooping = "Not Looping Alert" case missedBolus = "Missed Bolus Alert" + case futureCarbs = "Future Carbs Alert" case sensorChange = "Sensor Change Alert" case pumpChange = "Pump Change Alert" case pump = "Pump Insulin Alert" diff --git a/LoopFollow/Alarm/DataStructs/PendingFutureCarb.swift b/LoopFollow/Alarm/DataStructs/PendingFutureCarb.swift new file mode 100644 index 000000000..b28e9851a --- /dev/null +++ b/LoopFollow/Alarm/DataStructs/PendingFutureCarb.swift @@ -0,0 +1,17 @@ +// LoopFollow +// PendingFutureCarb.swift + +import Foundation + +/// Tracks a future-dated carb entry that has been observed but whose scheduled time +/// has not yet arrived. Used by `FutureCarbsCondition` to fire a reminder when it's time to eat. +struct PendingFutureCarb: Codable, Equatable { + /// Scheduled eating time (`timeIntervalSince1970`) + let carbDate: TimeInterval + + /// Grams of carbs (used together with `carbDate` to identify unique entries) + let grams: Double + + /// When the entry was first observed (`timeIntervalSince1970`, for staleness cleanup) + let observedAt: TimeInterval +} diff --git a/LoopFollow/Settings/ImportExport/AlarmSelectionView.swift b/LoopFollow/Settings/ImportExport/AlarmSelectionView.swift index f69d83181..3574eb2b2 100644 --- a/LoopFollow/Settings/ImportExport/AlarmSelectionView.swift +++ b/LoopFollow/Settings/ImportExport/AlarmSelectionView.swift @@ -213,6 +213,8 @@ struct AlarmSelectionRow: View { return "Not Looping Alert" case .missedBolus: return "Missed Bolus Alert" + case .futureCarbs: + return "Future Carbs Alert" case .sensorChange: return "Sensor Change Alert" case .pumpChange: diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 97e99bc7c..97e7a3d8c 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -59,6 +59,7 @@ class Storage { var lastRecBolusNotified = StorageValue(key: "lastRecBolusNotified", defaultValue: nil) var lastCOBNotified = StorageValue(key: "lastCOBNotified", defaultValue: nil) var lastMissedBolusNotified = StorageValue(key: "lastMissedBolusNotified", defaultValue: nil) + var pendingFutureCarbs = StorageValue<[PendingFutureCarb]>(key: "pendingFutureCarbs", defaultValue: []) // General Settings [BEGIN] var appBadge = StorageValue(key: "appBadge", defaultValue: true) From a649aac50fcab273d2c1d0369dabe0077603d02e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 22 Mar 2026 09:48:10 +0100 Subject: [PATCH 2/6] Add FutureCarbsCondition unit tests 10 test cases covering tracking, firing, deletion, lookahead bounds, min grams filter, past carbs, stale cleanup, multi-carb per-tick behavior, and duplicate prevention. Also fix Tests target missing FRAMEWORK_SEARCH_PATHS for CocoaPods dependencies and add missing latestPumpBattery field in withBattery test helper. --- LoopFollow.xcodeproj/project.pbxproj | 42 ++-- .../FutureCarbsConditionTests.swift | 215 ++++++++++++++++++ Tests/AlarmConditions/Helpers.swift | 35 ++- 3 files changed, 273 insertions(+), 19 deletions(-) create mode 100644 Tests/AlarmConditions/FutureCarbsConditionTests.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 43e169c63..e767b05c5 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 2D8068C66833EEAED7B4BEB8 /* FutureCarbsCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */; }; 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; }; 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */; }; @@ -35,9 +36,9 @@ 6589CC6F2E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC572E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift */; }; 6589CC712E9E814F00BB18FE /* AlarmSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */; }; 6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */; }; - 6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; }; 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; }; 65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; }; + ACE7F6DE0D065BEB52CDC0DB /* FutureCarbsAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */; }; DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; }; DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; }; DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */; }; @@ -200,17 +201,14 @@ DDC7E5472DBD8A1600EB1127 /* AlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC7E5402DBD8A1600EB1127 /* AlarmEditor.swift */; }; DDC7E5CF2DC77C2000EB1127 /* SnoozerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC7E5CE2DC77C2000EB1127 /* SnoozerViewModel.swift */; }; DDCC3A4B2DDBB5E4006F1C10 /* BatteryCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A4A2DDBB5E4006F1C10 /* BatteryCondition.swift */; }; - DDCC3A502DDED000006F1C10 /* PumpBatteryCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */; }; DDCC3A4D2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A4C2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift */; }; - DDCC3A5B2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */; }; DDCC3A4F2DDC5B54006F1C10 /* BatteryDropCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */; }; + DDCC3A502DDED000006F1C10 /* PumpBatteryCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.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 */; }; - F19449721F3B792730A0F4FD /* PendingFutureCarb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */; }; - 2D8068C66833EEAED7B4BEB8 /* FutureCarbsCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */; }; - ACE7F6DE0D065BEB52CDC0DB /* FutureCarbsAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */; }; + DDCC3A5B2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */; }; DDCF979424C0D380002C9752 /* UIViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979324C0D380002C9752 /* UIViewExtension.swift */; }; DDCF9A802D85FD0B004DF4DD /* Alarm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */; }; DDCF9A822D85FD15004DF4DD /* AlarmType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A812D85FD14004DF4DD /* AlarmType.swift */; }; @@ -254,6 +252,7 @@ DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */; }; DDFF3D872D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */; }; DDFF3D892D1429AB00BF9D9E /* BackgroundRefreshType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */; }; + F19449721F3B792730A0F4FD /* PendingFutureCarb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */; }; FC16A97A24996673003D6245 /* NightScout.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97924996673003D6245 /* NightScout.swift */; }; FC16A97B249966A3003D6245 /* AlarmSound.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC7CE589248ABEA3001F83B8 /* AlarmSound.swift */; }; FC16A97D24996747003D6245 /* SpeakBG.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97C24996747003D6245 /* SpeakBG.swift */; }; @@ -416,6 +415,8 @@ /* Begin PBXFileReference section */ 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.release.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.release.xcconfig"; sourceTree = ""; }; + 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingFutureCarb.swift; sourceTree = ""; }; + 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsCondition.swift; sourceTree = ""; }; 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQRCodeScannerView.swift; sourceTree = ""; }; 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPGenerator.swift; sourceTree = ""; }; 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetsView.swift; sourceTree = ""; }; @@ -443,10 +444,10 @@ 6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationModal.swift; sourceTree = ""; }; 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSelectionView.swift; sourceTree = ""; }; 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMigrationManager.swift; sourceTree = ""; }; - 6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = ""; }; 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = ""; }; 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = ""; }; A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsAlarmEditor.swift; sourceTree = ""; }; DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = ""; }; DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = ""; }; DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinPrecisionManager.swift; sourceTree = ""; }; @@ -608,17 +609,14 @@ DDC7E5402DBD8A1600EB1127 /* AlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmEditor.swift; sourceTree = ""; }; DDC7E5CE2DC77C2000EB1127 /* SnoozerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnoozerViewModel.swift; sourceTree = ""; }; DDCC3A4A2DDBB5E4006F1C10 /* BatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryCondition.swift; sourceTree = ""; }; - DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryCondition.swift; sourceTree = ""; }; DDCC3A4C2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneBatteryAlarmEditor.swift; sourceTree = ""; }; - DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryAlarmEditor.swift; sourceTree = ""; }; DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryDropCondition.swift; sourceTree = ""; }; + DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryCondition.swift; sourceTree = ""; }; DDCC3A532DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryDropAlarmEditor.swift; sourceTree = ""; }; DDCC3A552DDC9617006F1C10 /* MissedBolusCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedBolusCondition.swift; sourceTree = ""; }; DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedBolusAlarmEditor.swift; sourceTree = ""; }; DDCC3A592DDC988F006F1C10 /* CarbSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbSample.swift; sourceTree = ""; }; - 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingFutureCarb.swift; sourceTree = ""; }; - 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsCondition.swift; sourceTree = ""; }; - B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsAlarmEditor.swift; sourceTree = ""; }; + DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryAlarmEditor.swift; sourceTree = ""; }; DDCC3ABF2DDE10B0006F1C10 /* Testing.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Testing.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/Testing.framework; sourceTree = DEVELOPER_DIR; }; DDCC3AD62DDE1790006F1C10 /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DDCF979324C0D380002C9752 /* UIViewExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewExtension.swift; sourceTree = ""; }; @@ -2251,8 +2249,14 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(BUILT_PRODUCTS_DIR)/Charts", + "$(BUILT_PRODUCTS_DIR)/ShareClient", + "$(BUILT_PRODUCTS_DIR)/SwiftAlgorithms", + ); GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; @@ -2278,8 +2282,14 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(BUILT_PRODUCTS_DIR)/Charts", + "$(BUILT_PRODUCTS_DIR)/ShareClient", + "$(BUILT_PRODUCTS_DIR)/SwiftAlgorithms", + ); GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; @@ -2419,8 +2429,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = ECA3EFB4037410B4973BB632 /* Pods-LoopFollow.debug.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; @@ -2443,8 +2453,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; diff --git a/Tests/AlarmConditions/FutureCarbsConditionTests.swift b/Tests/AlarmConditions/FutureCarbsConditionTests.swift new file mode 100644 index 000000000..05cf581a4 --- /dev/null +++ b/Tests/AlarmConditions/FutureCarbsConditionTests.swift @@ -0,0 +1,215 @@ +// LoopFollow +// FutureCarbsConditionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@Suite(.serialized) +struct FutureCarbsConditionTests { + let cond = FutureCarbsCondition() + + private func resetPending() { + Storage.shared.pendingFutureCarbs.value = [] + } + + private func carb(minutesFromNow offset: Double, grams: Double = 20, relativeTo now: Date = .init()) -> CarbSample { + CarbSample(grams: grams, date: now.addingTimeInterval(offset * 60)) + } + + // MARK: - 1. Tracking — future carb within lookahead gets tracked + + @Test("#tracking — future carb within lookahead gets tracked") + func futureWithinLookaheadTracked() { + resetPending() + let now = Date() + let alarm = Alarm.futureCarbs(threshold: 45, delta: 5) + let data = AlarmData.withCarbs([carb(minutesFromNow: 10, grams: 20, relativeTo: now)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + } + + // MARK: - 2. Firing — pending carb whose time arrives fires + + @Test("#firing — pending carb whose time arrives fires") + func pendingCarbFires() { + resetPending() + let now = Date() + let pastDate = now.addingTimeInterval(-60) // 1 min ago + + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb(carbDate: pastDate.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([CarbSample(grams: 20, date: pastDate)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 3. Deleted carb — no fire, removed from pending + + @Test("#deleted carb — no fire, removed from pending") + func deletedCarbNoFire() { + resetPending() + let now = Date() + let pastDate = now.addingTimeInterval(-60) + + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb(carbDate: pastDate.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([]) // carb was deleted + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 4. Beyond lookahead — carb ignored + + @Test("#beyond lookahead — carb ignored") + func beyondLookaheadIgnored() { + resetPending() + let now = Date() + let alarm = Alarm.futureCarbs(threshold: 45) + let data = AlarmData.withCarbs([carb(minutesFromNow: 60, grams: 20, relativeTo: now)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 5. Below min grams — carb ignored + + @Test("#below min grams — carb ignored") + func belowMinGramsIgnored() { + resetPending() + let now = Date() + let alarm = Alarm.futureCarbs(delta: 5) + let data = AlarmData.withCarbs([carb(minutesFromNow: 10, grams: 3, relativeTo: now)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 6. Past carb — not tracked + + @Test("#past carb — not tracked") + func pastCarbNotTracked() { + resetPending() + let now = Date() + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([carb(minutesFromNow: -5, grams: 20, relativeTo: now)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 7. Stale cleanup — entry observed > 2h ago is removed + + @Test("#stale cleanup — entry observed > 2h ago is removed") + func staleCleanup() { + resetPending() + let now = Date() + let futureDate = now.addingTimeInterval(300) // still in the future + + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb(carbDate: futureDate.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-3 * 3600).timeIntervalSince1970), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 8. Multiple carbs — only one fires per tick + + @Test("#multiple carbs — only one fires per tick") + func multipleOnlyOnePerTick() { + resetPending() + let now = Date() + let past1 = now.addingTimeInterval(-60) + let past2 = now.addingTimeInterval(-120) + + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb(carbDate: past1.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + PendingFutureCarb(carbDate: past2.timeIntervalSince1970, grams: 30, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([ + CarbSample(grams: 20, date: past1), + CarbSample(grams: 30, date: past2), + ]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(result) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + } + + // MARK: - 9. Second tick fires second carb + + @Test("#second tick fires second carb") + func secondTickFiresSecond() { + resetPending() + let now = Date() + let past1 = now.addingTimeInterval(-60) + let past2 = now.addingTimeInterval(-120) + + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb(carbDate: past1.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + PendingFutureCarb(carbDate: past2.timeIntervalSince1970, grams: 30, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([ + CarbSample(grams: 20, date: past1), + CarbSample(grams: 30, date: past2), + ]) + + // First tick + let result1 = cond.evaluate(alarm: alarm, data: data, now: now) + #expect(result1) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + + // Second tick + let result2 = cond.evaluate(alarm: alarm, data: data, now: now) + #expect(result2) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 10. Duplicate carb not double-tracked + + @Test("#duplicate carb not double-tracked") + func duplicateNotDoubleTracked() { + resetPending() + let now = Date() + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([carb(minutesFromNow: 10, grams: 20, relativeTo: now)]) + + _ = cond.evaluate(alarm: alarm, data: data, now: now) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + + _ = cond.evaluate(alarm: alarm, data: data, now: now) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + } +} diff --git a/Tests/AlarmConditions/Helpers.swift b/Tests/AlarmConditions/Helpers.swift index 37220d12f..c615f4972 100644 --- a/Tests/AlarmConditions/Helpers.swift +++ b/Tests/AlarmConditions/Helpers.swift @@ -6,9 +6,6 @@ import Foundation @testable import LoopFollow import Testing -@testable import LoopFollow -import Testing - // MARK: - Alarm helpers extension Alarm { @@ -17,6 +14,13 @@ extension Alarm { alarm.threshold = threshold return alarm } + + static func futureCarbs(threshold: Double = 45, delta: Double = 5) -> Self { + var alarm = Alarm(type: .futureCarbs) + alarm.threshold = threshold + alarm.delta = delta + return alarm + } } // MARK: - AlarmData helpers @@ -40,8 +44,33 @@ extension AlarmData { IOB: nil, recentBoluses: [], latestBattery: level, + latestPumpBattery: nil, batteryHistory: [], recentCarbs: [] ) } + + static func withCarbs(_ carbs: [CarbSample]) -> Self { + AlarmData( + bgReadings: [], + predictionData: [], + expireDate: nil, + lastLoopTime: nil, + latestOverrideStart: nil, + latestOverrideEnd: nil, + latestTempTargetStart: nil, + latestTempTargetEnd: nil, + recBolus: nil, + COB: nil, + sageInsertTime: nil, + pumpInsertTime: nil, + latestPumpVolume: nil, + IOB: nil, + recentBoluses: [], + latestBattery: nil, + latestPumpBattery: nil, + batteryHistory: [], + recentCarbs: carbs + ) + } } From cbb684aea3ef5e0e66d703daf9f1d9ec1e05256e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 22 Mar 2026 09:48:19 +0100 Subject: [PATCH 3/6] Fix future-dated treatments not being downloaded from Nightscout Trio sets created_at to the scheduled future time, but the Nightscout query had an upper bound of "now", excluding any future-dated entries. Extend the query window by predictionToLoad minutes so treatments within the graph lookahead are fetched. Also add addingMinutes parameter to getDateTimeString for precise minute-level offsets. --- LoopFollow/Controllers/Nightscout/Treatments.swift | 3 ++- LoopFollow/Helpers/DateTime.swift | 10 +++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index 8ff20df87..3916dbc0d 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -10,7 +10,8 @@ extension MainViewController { if !Storage.shared.downloadTreatments.value { return } let startTimeString = dateTimeUtils.getDateTimeString(addingDays: -1 * Storage.shared.downloadDays.value) - let currentTimeString = dateTimeUtils.getDateTimeString() + let lookaheadMinutes = Int(Storage.shared.predictionToLoad.value * 60) + let currentTimeString = dateTimeUtils.getDateTimeString(addingMinutes: lookaheadMinutes) let estimatedCount = max(Storage.shared.downloadDays.value * 100, 5000) let parameters: [String: String] = [ "find[created_at][$gte]": startTimeString, diff --git a/LoopFollow/Helpers/DateTime.swift b/LoopFollow/Helpers/DateTime.swift index a4f31b914..8bbde95c2 100644 --- a/LoopFollow/Helpers/DateTime.swift +++ b/LoopFollow/Helpers/DateTime.swift @@ -69,16 +69,20 @@ class dateTimeUtils { return utcTime } - static func getDateTimeString(addingHours hours: Int? = nil, addingDays days: Int? = nil) -> String { + static func getDateTimeString(addingMinutes minutes: Int? = nil, addingHours hours: Int? = nil, addingDays days: Int? = nil) -> String { let currentDate = Date() var date = currentDate + if let minutesToAdd = minutes { + date = Calendar.current.date(byAdding: .minute, value: minutesToAdd, to: date)! + } + if let hoursToAdd = hours { - date = Calendar.current.date(byAdding: .hour, value: hoursToAdd, to: currentDate)! + date = Calendar.current.date(byAdding: .hour, value: hoursToAdd, to: date)! } if let daysToAdd = days { - date = Calendar.current.date(byAdding: .day, value: daysToAdd, to: currentDate)! + date = Calendar.current.date(byAdding: .day, value: daysToAdd, to: date)! } let dateFormatter = DateFormatter() From 749b2cb512d13d1fb2ba5056fa479b0d16918950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 22 Mar 2026 10:01:09 +0100 Subject: [PATCH 4/6] Default Future Carbs Alert to acknowledge instead of snooze --- LoopFollow/Alarm/Alarm.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/LoopFollow/Alarm/Alarm.swift b/LoopFollow/Alarm/Alarm.swift index ab6f7964e..8fff17f4d 100644 --- a/LoopFollow/Alarm/Alarm.swift +++ b/LoopFollow/Alarm/Alarm.swift @@ -313,6 +313,7 @@ struct Alarm: Identifiable, Codable, Equatable { soundFile = .alertToneRingtone1 threshold = 45 // max lookahead minutes delta = 5 // min grams + snoozeDuration = 0 repeatSoundOption = .never case .sensorChange: soundFile = .wakeUpWillYou From f316fbae4b9ca6fe076845f1880bb1f9567b95ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 23 Mar 2026 18:44:29 +0100 Subject: [PATCH 5/6] Download treatments 6 hours into the future Replace the prediction-based lookahead with a fixed 6-hour window and rename currentTimeString to endTimeString for clarity. --- LoopFollow/Controllers/Nightscout/Treatments.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index 3916dbc0d..307a37e79 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -10,12 +10,11 @@ extension MainViewController { if !Storage.shared.downloadTreatments.value { return } let startTimeString = dateTimeUtils.getDateTimeString(addingDays: -1 * Storage.shared.downloadDays.value) - let lookaheadMinutes = Int(Storage.shared.predictionToLoad.value * 60) - let currentTimeString = dateTimeUtils.getDateTimeString(addingMinutes: lookaheadMinutes) + let endTimeString = dateTimeUtils.getDateTimeString(addingHours: 6) let estimatedCount = max(Storage.shared.downloadDays.value * 100, 5000) let parameters: [String: String] = [ "find[created_at][$gte]": startTimeString, - "find[created_at][$lte]": currentTimeString, + "find[created_at][$lte]": endTimeString, "count": "\(estimatedCount)", ] NightscoutUtils.executeDynamicRequest(eventType: .treatments, parameters: parameters) { (result: Result) in From 21e65f8ac1c351e1d97a4ae56c42393444f2e4ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 28 Mar 2026 10:18:17 +0100 Subject: [PATCH 6/6] Fix max lookahead sliding window bug Carbs originally outside the lookahead window could drift into it over time and fire incorrectly. Now all future carbs are tracked from first observation, and only fire if their original distance (carbDate minus observedAt) was within the max lookahead. Stale cleanup also preserves entries whose carb still exists to prevent re-observation with a fresh timestamp. --- .../AlarmCondition/FutureCarbsCondition.swift | 49 +++++----- .../FutureCarbsConditionTests.swift | 93 ++++++++++++++++++- 2 files changed, 117 insertions(+), 25 deletions(-) diff --git a/LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift b/LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift index 8fa0a7a27..24631e5c9 100644 --- a/LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift @@ -6,13 +6,15 @@ import Foundation /// Fires once when a future-dated carb entry's scheduled time arrives. /// /// **How it works:** -/// 1. Each alarm tick scans `recentCarbs` for entries whose `date` is in the future -/// (within a configurable max lookahead window). New ones are added to a persistent -/// "pending" list. +/// 1. Each alarm tick scans `recentCarbs` for entries whose `date` is in the future. +/// New ones are added to a persistent "pending" list regardless of lookahead distance, +/// capturing the moment they were first observed (`observedAt`). /// 2. When a pending entry's `carbDate` passes (i.e. `carbDate <= now`), verify the -/// carb still exists in `recentCarbs`. If so, fire the alarm. If the carb was -/// deleted, silently remove it. -/// 3. Stale entries (observed > 2 hours ago) are cleaned up automatically. +/// carb still exists in `recentCarbs` **and** that the original distance +/// (`carbDate − observedAt`) was within the max lookahead window. If both hold, +/// fire the alarm. Otherwise silently remove the entry. +/// 3. Stale entries (observed > 2 hours ago) whose carb no longer exists in +/// `recentCarbs` are cleaned up automatically. struct FutureCarbsCondition: AlarmCondition { static let type: AlarmType = .futureCarbs init() {} @@ -36,9 +38,11 @@ struct FutureCarbsCondition: AlarmCondition { for carb in data.recentCarbs { let carbTI = carb.date.timeIntervalSince1970 - // Must be in the future and within the lookahead window + // Must be in the future and meet the minimum grams threshold. + // We track ALL future carbs (not just those within the lookahead + // window) so that carbs originally outside the window cannot + // drift in later with a fresh observedAt. guard carbTI > nowTI, - carbTI - nowTI <= maxLookaheadSec, carb.grams >= minGrams else { continue } @@ -61,27 +65,30 @@ struct FutureCarbsCondition: AlarmCondition { var fired = false pending.removeAll { entry in - // Cleanup stale entries (observed > 2 hours ago) - if nowTI - entry.observedAt > 7200 { + let stillExists = data.recentCarbs.contains { carb in + abs(carb.date.timeIntervalSince1970 - entry.carbDate) < tolerance + && carb.grams == entry.grams + } + + // Cleanup stale entries (observed > 2 hours ago) only if + // the carb no longer exists — prevents eviction and + // re-observation with a fresh observedAt. + if nowTI - entry.observedAt > 7200, !stillExists { return true } // Not yet due guard entry.carbDate <= nowTI else { return false } - // Due — verify carb still exists in recentCarbs - let stillExists = data.recentCarbs.contains { carb in - abs(carb.date.timeIntervalSince1970 - entry.carbDate) < tolerance - && carb.grams == entry.grams - } + // Carb was deleted — remove silently + if !stillExists { return true } - if stillExists, !fired { - fired = true - return true // remove from pending after firing - } + // Carb was originally outside the lookahead window — remove without firing + if entry.carbDate - entry.observedAt > maxLookaheadSec { return true } - // Carb was deleted or we already fired this tick — remove silently - if !stillExists { + // Fire (one per tick) + if !fired { + fired = true return true } diff --git a/Tests/AlarmConditions/FutureCarbsConditionTests.swift b/Tests/AlarmConditions/FutureCarbsConditionTests.swift index 05cf581a4..878f78ab2 100644 --- a/Tests/AlarmConditions/FutureCarbsConditionTests.swift +++ b/Tests/AlarmConditions/FutureCarbsConditionTests.swift @@ -74,10 +74,10 @@ struct FutureCarbsConditionTests { #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) } - // MARK: - 4. Beyond lookahead — carb ignored + // MARK: - 4. Beyond lookahead — tracked but does not fire - @Test("#beyond lookahead — carb ignored") - func beyondLookaheadIgnored() { + @Test("#beyond lookahead — tracked but does not fire") + func beyondLookaheadTrackedButNoFire() { resetPending() let now = Date() let alarm = Alarm.futureCarbs(threshold: 45) @@ -86,7 +86,9 @@ struct FutureCarbsConditionTests { let result = cond.evaluate(alarm: alarm, data: data, now: now) #expect(!result) - #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + // Carb is tracked (to prevent re-observation with fresh observedAt) + // but will never fire because original distance > lookahead + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) } // MARK: - 5. Below min grams — carb ignored @@ -212,4 +214,87 @@ struct FutureCarbsConditionTests { _ = cond.evaluate(alarm: alarm, data: data, now: now) #expect(Storage.shared.pendingFutureCarbs.value.count == 1) } + + // MARK: - 11. Sliding window — carb outside lookahead never fires + + @Test("#sliding window — carb outside lookahead never fires") + func slidingWindowNeverFires() { + resetPending() + let t0 = Date() + let alarm = Alarm.futureCarbs(threshold: 10) // 10-minute lookahead + let carbDate = t0.addingTimeInterval(15 * 60) // 15 min in future + let carbSample = CarbSample(grams: 20, date: carbDate) + + // Tick at T+0: carb is 15 min away, outside 10-min window but tracked + let data = AlarmData.withCarbs([carbSample]) + let r0 = cond.evaluate(alarm: alarm, data: data, now: t0) + #expect(!r0) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + + // Tick at T+5min: carb is now 10 min away (inside window), but + // original distance was 15 min — must NOT fire + let t1 = t0.addingTimeInterval(5 * 60) + let r1 = cond.evaluate(alarm: alarm, data: data, now: t1) + #expect(!r1) + + // Tick at T+15min: carb is due — still must NOT fire + let t2 = t0.addingTimeInterval(15 * 60) + let r2 = cond.evaluate(alarm: alarm, data: data, now: t2) + #expect(!r2) + // Entry should be removed (due, outside original window) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 12. Due entry outside original window removed without firing + + @Test("#due entry outside original window removed without firing") + func dueOutsideWindowRemovedNoFire() { + resetPending() + let now = Date() + let pastDate = now.addingTimeInterval(-60) // 1 min ago + + // Entry was observed 20 min before its carb date (outside 10-min window) + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb( + carbDate: pastDate.timeIntervalSince1970, + grams: 20, + observedAt: pastDate.timeIntervalSince1970 - 20 * 60 + ), + ] + + let alarm = Alarm.futureCarbs(threshold: 10) + let data = AlarmData.withCarbs([CarbSample(grams: 20, date: pastDate)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 13. Stale entry with existing carb is not evicted + + @Test("#stale entry with existing carb is not evicted") + func staleWithExistingCarbNotEvicted() { + resetPending() + let now = Date() + let futureDate = now.addingTimeInterval(300) // 5 min in the future + + // Entry observed 3 hours ago, but carb still exists in recentCarbs + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb( + carbDate: futureDate.timeIntervalSince1970, + grams: 20, + observedAt: now.addingTimeInterval(-3 * 3600).timeIntervalSince1970 + ), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([CarbSample(grams: 20, date: futureDate)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + // Entry must survive — carb still exists, don't evict + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + } }