Skip to content

Commit 97d30e9

Browse files
authored
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.
1 parent 9d4ce02 commit 97d30e9

File tree

16 files changed

+557
-21
lines changed

16 files changed

+557
-21
lines changed

LoopFollow.xcodeproj/project.pbxproj

Lines changed: 32 additions & 10 deletions
Large diffs are not rendered by default.

LoopFollow/Alarm/Alarm.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,12 @@ struct Alarm: Identifiable, Codable, Equatable {
309309
predictiveMinutes = 15
310310
delta = 0.1
311311
threshold = 4
312+
case .futureCarbs:
313+
soundFile = .alertToneRingtone1
314+
threshold = 45 // max lookahead minutes
315+
delta = 5 // min grams
316+
snoozeDuration = 0
317+
repeatSoundOption = .never
312318
case .sensorChange:
313319
soundFile = .wakeUpWillYou
314320
threshold = 12
@@ -364,7 +370,7 @@ extension AlarmType {
364370
switch self {
365371
case .low, .high, .fastDrop, .fastRise, .missedReading, .temporary:
366372
return .glucose
367-
case .iob, .cob, .missedBolus, .recBolus:
373+
case .iob, .cob, .missedBolus, .futureCarbs, .recBolus:
368374
return .insulin
369375
case .battery, .batteryDrop, .pump, .pumpBattery, .pumpChange,
370376
.sensorChange, .notLooping, .buildExpire:
@@ -384,6 +390,7 @@ extension AlarmType {
384390
case .iob: return "syringe"
385391
case .cob: return "fork.knife"
386392
case .missedBolus: return "exclamationmark.arrow.triangle.2.circlepath"
393+
case .futureCarbs: return "clock.arrow.circlepath"
387394
case .recBolus: return "bolt.horizontal"
388395
case .battery: return "battery.25"
389396
case .batteryDrop: return "battery.100.bolt"
@@ -411,6 +418,7 @@ extension AlarmType {
411418
case .iob: return "High insulin-on-board."
412419
case .cob: return "High carbs-on-board."
413420
case .missedBolus: return "Carbs without bolus."
421+
case .futureCarbs: return "Reminder when future carbs are due."
414422
case .recBolus: return "Recommended bolus issued."
415423
case .battery: return "Phone battery low."
416424
case .batteryDrop: return "Battery drops quickly."
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// LoopFollow
2+
// FutureCarbsCondition.swift
3+
4+
import Foundation
5+
6+
/// Fires once when a future-dated carb entry's scheduled time arrives.
7+
///
8+
/// **How it works:**
9+
/// 1. Each alarm tick scans `recentCarbs` for entries whose `date` is in the future.
10+
/// New ones are added to a persistent "pending" list regardless of lookahead distance,
11+
/// capturing the moment they were first observed (`observedAt`).
12+
/// 2. When a pending entry's `carbDate` passes (i.e. `carbDate <= now`), verify the
13+
/// carb still exists in `recentCarbs` **and** that the original distance
14+
/// (`carbDate − observedAt`) was within the max lookahead window. If both hold,
15+
/// fire the alarm. Otherwise silently remove the entry.
16+
/// 3. Stale entries (observed > 2 hours ago) whose carb no longer exists in
17+
/// `recentCarbs` are cleaned up automatically.
18+
struct FutureCarbsCondition: AlarmCondition {
19+
static let type: AlarmType = .futureCarbs
20+
init() {}
21+
22+
func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool {
23+
// ────────────────────────────────
24+
// 0. Pull settings
25+
// ────────────────────────────────
26+
let maxLookaheadMin = alarm.threshold ?? 45 // max lookahead in minutes
27+
let minGrams = alarm.delta ?? 5 // ignore carbs below this
28+
29+
let nowTI = now.timeIntervalSince1970
30+
let maxLookaheadSec = maxLookaheadMin * 60
31+
32+
var pending = Storage.shared.pendingFutureCarbs.value
33+
let tolerance: TimeInterval = 5 // seconds, for matching carb entries
34+
35+
// ────────────────────────────────
36+
// 1. Scan for new future carbs
37+
// ────────────────────────────────
38+
for carb in data.recentCarbs {
39+
let carbTI = carb.date.timeIntervalSince1970
40+
41+
// Must be in the future and meet the minimum grams threshold.
42+
// We track ALL future carbs (not just those within the lookahead
43+
// window) so that carbs originally outside the window cannot
44+
// drift in later with a fresh observedAt.
45+
guard carbTI > nowTI,
46+
carb.grams >= minGrams
47+
else { continue }
48+
49+
// Already tracked?
50+
let alreadyTracked = pending.contains { entry in
51+
abs(entry.carbDate - carbTI) < tolerance && entry.grams == carb.grams
52+
}
53+
if !alreadyTracked {
54+
pending.append(PendingFutureCarb(
55+
carbDate: carbTI,
56+
grams: carb.grams,
57+
observedAt: nowTI
58+
))
59+
}
60+
}
61+
62+
// ────────────────────────────────
63+
// 2. Check if any pending entry is due
64+
// ────────────────────────────────
65+
var fired = false
66+
67+
pending.removeAll { entry in
68+
let stillExists = data.recentCarbs.contains { carb in
69+
abs(carb.date.timeIntervalSince1970 - entry.carbDate) < tolerance
70+
&& carb.grams == entry.grams
71+
}
72+
73+
// Cleanup stale entries (observed > 2 hours ago) only if
74+
// the carb no longer exists — prevents eviction and
75+
// re-observation with a fresh observedAt.
76+
if nowTI - entry.observedAt > 7200, !stillExists {
77+
return true
78+
}
79+
80+
// Not yet due
81+
guard entry.carbDate <= nowTI else { return false }
82+
83+
// Carb was deleted — remove silently
84+
if !stillExists { return true }
85+
86+
// Carb was originally outside the lookahead window — remove without firing
87+
if entry.carbDate - entry.observedAt > maxLookaheadSec { return true }
88+
89+
// Fire (one per tick)
90+
if !fired {
91+
fired = true
92+
return true
93+
}
94+
95+
return false
96+
}
97+
98+
// ────────────────────────────────
99+
// 3. Persist and return
100+
// ────────────────────────────────
101+
Storage.shared.pendingFutureCarbs.value = pending
102+
return fired
103+
}
104+
}

LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ struct AlarmEditor: View {
8282
case .battery: PhoneBatteryAlarmEditor(alarm: $alarm)
8383
case .batteryDrop: BatteryDropAlarmEditor(alarm: $alarm)
8484
case .missedBolus: MissedBolusAlarmEditor(alarm: $alarm)
85+
case .futureCarbs: FutureCarbsAlarmEditor(alarm: $alarm)
8586
}
8687
}
8788
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// LoopFollow
2+
// FutureCarbsAlarmEditor.swift
3+
4+
import SwiftUI
5+
6+
struct FutureCarbsAlarmEditor: View {
7+
@Binding var alarm: Alarm
8+
9+
var body: some View {
10+
Group {
11+
InfoBanner(
12+
text: "Alerts when a future-dated carb entry's scheduled time arrives — " +
13+
"a reminder to start eating. Use the max lookahead to ignore " +
14+
"fat/protein entries that are typically scheduled further ahead.",
15+
alarmType: alarm.type
16+
)
17+
18+
AlarmGeneralSection(alarm: $alarm)
19+
20+
AlarmStepperSection(
21+
header: "Max Lookahead",
22+
footer: "Only track carb entries scheduled up to this many minutes " +
23+
"in the future. Entries beyond this window are ignored.",
24+
title: "Lookahead",
25+
range: 5 ... 120,
26+
step: 5,
27+
unitLabel: "min",
28+
value: $alarm.threshold
29+
)
30+
31+
AlarmStepperSection(
32+
header: "Minimum Carbs",
33+
footer: "Ignore carb entries below this amount.",
34+
title: "At or Above",
35+
range: 0 ... 50,
36+
step: 1,
37+
unitLabel: "g",
38+
value: $alarm.delta
39+
)
40+
41+
AlarmActiveSection(alarm: $alarm)
42+
AlarmAudioSection(alarm: $alarm)
43+
AlarmSnoozeSection(alarm: $alarm)
44+
}
45+
}
46+
}

LoopFollow/Alarm/AlarmManager.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class AlarmManager {
3333
IOBCondition.self,
3434
BatteryCondition.self,
3535
BatteryDropCondition.self,
36+
FutureCarbsCondition.self,
3637
]
3738
) {
3839
var dict = [AlarmType: AlarmCondition]()

LoopFollow/Alarm/AlarmType/AlarmType+Snooze.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ extension AlarmType {
1111
return .day
1212
case .low, .high, .fastDrop, .fastRise,
1313
.missedReading, .notLooping, .missedBolus,
14-
.recBolus,
14+
.futureCarbs, .recBolus,
1515
.overrideStart, .overrideEnd, .tempTargetStart,
1616
.tempTargetEnd:
1717
return .minute

LoopFollow/Alarm/AlarmType/AlarmType+canAcknowledge.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ extension AlarmType {
88
var canAcknowledge: Bool {
99
switch self {
1010
// These are alarms that typically has a "memory", they will only alarm once and acknowledge them is fine
11-
case .low, .high, .fastDrop, .fastRise, .temporary, .cob, .missedBolus, .recBolus, .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd:
11+
case .low, .high, .fastDrop, .fastRise, .temporary, .cob, .missedBolus, .futureCarbs, .recBolus, .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd:
1212
return true
1313
// These are alarms without memory, if they only are acknowledged - they would alarm again immediately
1414
case

LoopFollow/Alarm/AlarmType/AlarmType.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ enum AlarmType: String, CaseIterable, Codable {
1616
case missedReading = "Missed Reading Alert"
1717
case notLooping = "Not Looping Alert"
1818
case missedBolus = "Missed Bolus Alert"
19+
case futureCarbs = "Future Carbs Alert"
1920
case sensorChange = "Sensor Change Alert"
2021
case pumpChange = "Pump Change Alert"
2122
case pump = "Pump Insulin Alert"
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// LoopFollow
2+
// PendingFutureCarb.swift
3+
4+
import Foundation
5+
6+
/// Tracks a future-dated carb entry that has been observed but whose scheduled time
7+
/// has not yet arrived. Used by `FutureCarbsCondition` to fire a reminder when it's time to eat.
8+
struct PendingFutureCarb: Codable, Equatable {
9+
/// Scheduled eating time (`timeIntervalSince1970`)
10+
let carbDate: TimeInterval
11+
12+
/// Grams of carbs (used together with `carbDate` to identify unique entries)
13+
let grams: Double
14+
15+
/// When the entry was first observed (`timeIntervalSince1970`, for staleness cleanup)
16+
let observedAt: TimeInterval
17+
}

0 commit comments

Comments
 (0)