Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 32 additions & 10 deletions LoopFollow.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion LoopFollow/Alarm/Alarm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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"
Expand Down Expand Up @@ -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."
Expand Down
104 changes: 104 additions & 0 deletions LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 46 additions & 0 deletions LoopFollow/Alarm/AlarmEditing/Editors/FutureCarbsAlarmEditor.swift
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions LoopFollow/Alarm/AlarmManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class AlarmManager {
IOBCondition.self,
BatteryCondition.self,
BatteryDropCondition.self,
FutureCarbsCondition.self,
]
) {
var dict = [AlarmType: AlarmCondition]()
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Alarm/AlarmType/AlarmType+Snooze.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Alarm/AlarmType/AlarmType+canAcknowledge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions LoopFollow/Alarm/AlarmType/AlarmType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
17 changes: 17 additions & 0 deletions LoopFollow/Alarm/DataStructs/PendingFutureCarb.swift
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 2 additions & 2 deletions LoopFollow/Controllers/Nightscout/Treatments.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions LoopFollow/Helpers/DateTime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions LoopFollow/Settings/ImportExport/AlarmSelectionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions LoopFollow/Storage/Storage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading