Przeglądaj źródła

Add Future Carbs Alert (#571)

* 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).

* 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.

* 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.

* Default Future Carbs Alert to acknowledge instead of snooze

* 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.

* 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.
Jonas Björkert 4 miesięcy temu
rodzic
commit
97d30e99fc

+ 32 - 10
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,14 +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 */; };
+		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 */; };
@@ -251,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 */; };
@@ -413,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 = "<group>"; };
+		2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingFutureCarb.swift; sourceTree = "<group>"; };
+		2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsCondition.swift; sourceTree = "<group>"; };
 		654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQRCodeScannerView.swift; sourceTree = "<group>"; };
 		654132E92E19F24800BDBE08 /* TOTPGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPGenerator.swift; sourceTree = "<group>"; };
 		654134172E1DC09700BDBE08 /* OverridePresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetsView.swift; sourceTree = "<group>"; };
@@ -440,10 +444,10 @@
 		6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationModal.swift; sourceTree = "<group>"; };
 		6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSelectionView.swift; sourceTree = "<group>"; };
 		6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMigrationManager.swift; sourceTree = "<group>"; };
-		6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = "<group>"; };
 		65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = "<group>"; };
 		65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = "<group>"; };
 		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 = "<group>"; };
 		DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = "<group>"; };
 		DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = "<group>"; };
 		DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinPrecisionManager.swift; sourceTree = "<group>"; };
@@ -605,14 +609,14 @@
 		DDC7E5402DBD8A1600EB1127 /* AlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmEditor.swift; sourceTree = "<group>"; };
 		DDC7E5CE2DC77C2000EB1127 /* SnoozerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnoozerViewModel.swift; sourceTree = "<group>"; };
 		DDCC3A4A2DDBB5E4006F1C10 /* BatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryCondition.swift; sourceTree = "<group>"; };
-		DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryCondition.swift; sourceTree = "<group>"; };
 		DDCC3A4C2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneBatteryAlarmEditor.swift; sourceTree = "<group>"; };
-		DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryAlarmEditor.swift; sourceTree = "<group>"; };
 		DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryDropCondition.swift; sourceTree = "<group>"; };
+		DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryCondition.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>"; };
+		DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryAlarmEditor.swift; sourceTree = "<group>"; };
 		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 = "<group>"; };
@@ -897,6 +901,7 @@
 			isa = PBXGroup;
 			children = (
 				DDCC3A552DDC9617006F1C10 /* MissedBolusCondition.swift */,
+				2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */,
 				DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */,
 				DDCC3A4A2DDBB5E4006F1C10 /* BatteryCondition.swift */,
 				DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */,
@@ -1143,6 +1148,7 @@
 			isa = PBXGroup;
 			children = (
 				DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */,
+				B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */,
 				DDCC3A532DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift */,
 				DDCC3A4C2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift */,
 				DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */,
@@ -1194,6 +1200,7 @@
 			isa = PBXGroup;
 			children = (
 				DDCC3A592DDC988F006F1C10 /* CarbSample.swift */,
+				2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */,
 				DDB9FC7E2DDB584500EFAA76 /* BolusEntry.swift */,
 				DD5DA27B2DC930D6003D44FC /* GlucoseValue.swift */,
 			);
@@ -1993,6 +2000,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 */,
@@ -2239,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;
@@ -2266,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;
@@ -2407,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;
@@ -2431,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;

+ 9 - 1
LoopFollow/Alarm/Alarm.swift

@@ -309,6 +309,12 @@ 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
+            snoozeDuration = 0
+            repeatSoundOption = .never
         case .sensorChange:
             soundFile = .wakeUpWillYou
             threshold = 12
@@ -364,7 +370,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 +390,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 +418,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."

+ 104 - 0
LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift

@@ -0,0 +1,104 @@
+// 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.
+///    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` **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() {}
+
+    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 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,
+                  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
+            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 }
+
+            // Carb was deleted — remove silently
+            if !stillExists { return true }
+
+            // Carb was originally outside the lookahead window — remove without firing
+            if entry.carbDate - entry.observedAt > maxLookaheadSec { return true }
+
+            // Fire (one per tick)
+            if !fired {
+                fired = true
+                return true
+            }
+
+            return false
+        }
+
+        // ────────────────────────────────
+        // 3. Persist and return
+        // ────────────────────────────────
+        Storage.shared.pendingFutureCarbs.value = pending
+        return fired
+    }
+}

+ 1 - 0
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)
         }
     }
 }

+ 46 - 0
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)
+        }
+    }
+}

+ 1 - 0
LoopFollow/Alarm/AlarmManager.swift

@@ -33,6 +33,7 @@ class AlarmManager {
             IOBCondition.self,
             BatteryCondition.self,
             BatteryDropCondition.self,
+            FutureCarbsCondition.self,
         ]
     ) {
         var dict = [AlarmType: AlarmCondition]()

+ 1 - 1
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

+ 1 - 1
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

+ 1 - 0
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"

+ 17 - 0
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
+}

+ 2 - 2
LoopFollow/Controllers/Nightscout/Treatments.swift

@@ -10,11 +10,11 @@ extension MainViewController {
         if !Storage.shared.downloadTreatments.value { return }
 
         let startTimeString = dateTimeUtils.getDateTimeString(addingDays: -1 * Storage.shared.downloadDays.value)
-        let currentTimeString = dateTimeUtils.getDateTimeString()
+        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<Any, Error>) in

+ 7 - 3
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()

+ 2 - 0
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:

+ 1 - 0
LoopFollow/Storage/Storage.swift

@@ -59,6 +59,7 @@ class Storage {
     var lastRecBolusNotified = StorageValue<Double?>(key: "lastRecBolusNotified", defaultValue: nil)
     var lastCOBNotified = StorageValue<Double?>(key: "lastCOBNotified", defaultValue: nil)
     var lastMissedBolusNotified = StorageValue<Date?>(key: "lastMissedBolusNotified", defaultValue: nil)
+    var pendingFutureCarbs = StorageValue<[PendingFutureCarb]>(key: "pendingFutureCarbs", defaultValue: [])
 
     // General Settings [BEGIN]
     var appBadge = StorageValue<Bool>(key: "appBadge", defaultValue: true)

+ 300 - 0
Tests/AlarmConditions/FutureCarbsConditionTests.swift

@@ -0,0 +1,300 @@
+// 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 — tracked but does not fire
+
+    @Test("#beyond lookahead — tracked but does not fire")
+    func beyondLookaheadTrackedButNoFire() {
+        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)
+        // 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
+
+    @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)
+    }
+
+    // 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)
+    }
+}

+ 32 - 3
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
+        )
+    }
 }