Skip to content
Open
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
97 changes: 97 additions & 0 deletions LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
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