From 952591977804cc7a3282e9a252301795fbbb8c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 23 Jul 2026 19:34:42 +0200 Subject: [PATCH 1/2] Add an early warning option to the temp target and override end alarms The end alarms can now also fire a chosen number of minutes before the scheduled end, titled Temp Target Ending Soon or Override Ending Soon, and still fire normally when the event actually ends. Default is 0 which keeps the current behavior. Overrides keep their unclamped scheduled end so indefinite or long overrides do not warn incorrectly. Includes unit tests for both conditions. --- LoopFollow/Alarm/Alarm.swift | 6 +- .../Alarm/AlarmCondition/AlarmCondition.swift | 5 + .../AlarmCondition/OverrideEndCondition.swift | 40 +++++- .../TempTargetEndCondition.swift | 41 ++++-- LoopFollow/Alarm/AlarmData.swift | 4 + .../Editors/OverrideEndAlarmEditor.swift | 13 +- .../Editors/TempTargetEndAlarmEditor.swift | 13 +- LoopFollow/Alarm/AlarmManager.swift | 5 +- .../Nightscout/Treatments/Overrides.swift | 12 +- LoopFollow/Helpers/DataStructs.swift | 4 + LoopFollow/Snoozer/SnoozerView.swift | 2 +- LoopFollow/Snoozer/SnoozerViewModel.swift | 2 + LoopFollow/Storage/Observable.swift | 3 + LoopFollow/Storage/Storage.swift | 2 + LoopFollow/Task/AlarmTask.swift | 4 + Tests/AlarmConditions/Helpers.swift | 68 ++++++++++ .../OverrideEndConditionTests.swift | 118 ++++++++++++++++ .../TempTargetEndConditionTests.swift | 126 ++++++++++++++++++ 18 files changed, 444 insertions(+), 24 deletions(-) create mode 100644 Tests/AlarmConditions/OverrideEndConditionTests.swift create mode 100644 Tests/AlarmConditions/TempTargetEndConditionTests.swift diff --git a/LoopFollow/Alarm/Alarm.swift b/LoopFollow/Alarm/Alarm.swift index f429aa04a..8680aafba 100644 --- a/LoopFollow/Alarm/Alarm.swift +++ b/LoopFollow/Alarm/Alarm.swift @@ -220,8 +220,8 @@ struct Alarm: Identifiable, Codable, Equatable { /// Function for when the alarm is triggered. /// If this alarm, all alarms is disabled or snoozed, then should not be called. This or all alarmd could be muted, then this function will just generate a notification. - func trigger(config: AlarmConfiguration, now: Date) { - LogManager.shared.log(category: .alarm, message: "Alarm triggered: \(type.rawValue)") + func trigger(config: AlarmConfiguration, now: Date, titleOverride: String? = nil) { + LogManager.shared.log(category: .alarm, message: "Alarm triggered: \(titleOverride ?? type.rawValue)") var playSound = true @@ -277,7 +277,7 @@ struct Alarm: Identifiable, Codable, Equatable { } }() - AlarmManager.shared.sendNotification(title: type.rawValue, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze") + AlarmManager.shared.sendNotification(title: titleOverride ?? type.rawValue, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze") if playSound { AlarmSound.setSoundFile(soundFile) diff --git a/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift b/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift index 8e60bcb9d..d8e90bba7 100644 --- a/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift @@ -8,6 +8,9 @@ protocol AlarmCondition { init() /// pure, per-alarm logic against `AlarmData` func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool + /// Notification title for the firing that just evaluated true; nil (the + /// default) uses the alarm type's name. + func notificationTitle(alarm: Alarm, data: AlarmData, now: Date) -> String? /// `true` when `belowBG`/`aboveBG` are this condition's own trigger /// threshold, checked in `evaluate`; `false` (the default) treats them /// as activation limits enforced by `passesBGLimits`. @@ -17,6 +20,8 @@ protocol AlarmCondition { extension AlarmCondition { var checksOwnBGLimits: Bool { false } + func notificationTitle(alarm _: Alarm, data _: AlarmData, now _: Date) -> String? { nil } + /// Returns `true` when the alarm is allowed to continue evaluating /// after BG-limit checks; `false` blocks it immediately. func passesBGLimits(alarm: Alarm, data: AlarmData) -> Bool { diff --git a/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift b/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift index e54ebd08d..0edbe188b 100644 --- a/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift @@ -3,18 +3,44 @@ import Foundation +/// Fires once when the active override ends and, if the alarm's +/// `predictiveMinutes` is set, once that many minutes before the scheduled end. struct OverrideEndCondition: AlarmCondition { static let type: AlarmType = .overrideEnd init() {} - func evaluate(alarm _: Alarm, data: AlarmData, now: Date) -> Bool { - guard let endTS = data.latestOverrideEnd, endTS > 0 else { return false } - guard now.timeIntervalSince1970 - endTS <= 15 * 60 else { return false } + func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool { + // The ended phase must stay ahead of the early-warning phase; + // notificationTitle(alarm:data:now:) relies on this ordering. + if let endTS = data.latestOverrideEnd, endTS > 0, + now.timeIntervalSince1970 - endTS <= 15 * 60 + { + let last = Storage.shared.lastOverrideEndNotified.value ?? 0 + if endTS > last { + Storage.shared.lastOverrideEndNotified.value = endTS + return true + } + } - let last = Storage.shared.lastOverrideEndNotified.value ?? 0 - guard endTS > last else { return false } + if let lead = alarm.predictiveMinutes, lead > 0, + let endTS = data.activeOverrideEnd, + now.timeIntervalSince1970 >= endTS - Double(lead) * 60 + { + let last = Storage.shared.lastOverridePreEndNotified.value ?? 0 + if endTS > last { + Storage.shared.lastOverridePreEndNotified.value = endTS + return true + } + } - Storage.shared.lastOverrideEndNotified.value = endTS - return true + return false + } + + func notificationTitle(alarm _: Alarm, data: AlarmData, now: Date) -> String? { + guard let endTS = data.activeOverrideEnd, + now.timeIntervalSince1970 < endTS, + Storage.shared.lastOverridePreEndNotified.value == endTS + else { return nil } + return "Override Ending Soon" } } diff --git a/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift b/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift index 207332e96..708976cf6 100644 --- a/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift @@ -3,19 +3,44 @@ import Foundation -/// Fires once when the active temp target ends. +/// Fires once when the active temp target ends and, if the alarm's +/// `predictiveMinutes` is set, once that many minutes before the scheduled end. struct TempTargetEndCondition: AlarmCondition { static let type: AlarmType = .tempTargetEnd init() {} - func evaluate(alarm _: Alarm, data: AlarmData, now: Date) -> Bool { - guard let endTS = data.latestTempTargetEnd, endTS > 0 else { return false } - guard now.timeIntervalSince1970 - endTS <= 15 * 60 else { return false } + func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool { + // The ended phase must stay ahead of the early-warning phase; + // notificationTitle(alarm:data:now:) relies on this ordering. + if let endTS = data.latestTempTargetEnd, endTS > 0, + now.timeIntervalSince1970 - endTS <= 15 * 60 + { + let last = Storage.shared.lastTempTargetEndNotified.value ?? 0 + if endTS > last { + Storage.shared.lastTempTargetEndNotified.value = endTS + return true + } + } - let last = Storage.shared.lastTempTargetEndNotified.value ?? 0 - guard endTS > last else { return false } + if let lead = alarm.predictiveMinutes, lead > 0, + let endTS = data.activeTempTargetEnd, + now.timeIntervalSince1970 >= endTS - Double(lead) * 60 + { + let last = Storage.shared.lastTempTargetPreEndNotified.value ?? 0 + if endTS > last { + Storage.shared.lastTempTargetPreEndNotified.value = endTS + return true + } + } - Storage.shared.lastTempTargetEndNotified.value = endTS - return true + return false + } + + func notificationTitle(alarm _: Alarm, data: AlarmData, now: Date) -> String? { + guard let endTS = data.activeTempTargetEnd, + now.timeIntervalSince1970 < endTS, + Storage.shared.lastTempTargetPreEndNotified.value == endTS + else { return nil } + return "Temp Target Ending Soon" } } diff --git a/LoopFollow/Alarm/AlarmData.swift b/LoopFollow/Alarm/AlarmData.swift index 588c37c5f..dcf6bf946 100644 --- a/LoopFollow/Alarm/AlarmData.swift +++ b/LoopFollow/Alarm/AlarmData.swift @@ -12,6 +12,10 @@ struct AlarmData: Codable { let latestOverrideEnd: TimeInterval? let latestTempTargetStart: TimeInterval? let latestTempTargetEnd: TimeInterval? + /// Scheduled end of the currently-active override/temp target, if known + /// in advance — used by the end alarms' early warning. + var activeOverrideEnd: TimeInterval? + var activeTempTargetEnd: TimeInterval? let recBolus: Double? let COB: Double? let sageInsertTime: TimeInterval? diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/OverrideEndAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/OverrideEndAlarmEditor.swift index 5d88a527a..adf465c83 100644 --- a/LoopFollow/Alarm/AlarmEditing/Editors/OverrideEndAlarmEditor.swift +++ b/LoopFollow/Alarm/AlarmEditing/Editors/OverrideEndAlarmEditor.swift @@ -8,10 +8,21 @@ struct OverrideEndAlarmEditor: View { var body: some View { Group { - InfoBanner(text: "Alerts when an override ends.", alarmType: alarm.type) + InfoBanner(text: "Alerts when an override ends, with an optional early warning before the scheduled end.", alarmType: alarm.type) AlarmGeneralSection(alarm: $alarm) + AlarmStepperSection( + header: "Early Warning", + footer: "Also alert this many minutes before the override " + + "is scheduled to end. Set to 0 to alert only when it ends.", + title: "Warn before end", + range: 0 ... 30, + step: 5, + unitLabel: alarm.type.snoozeTimeUnit.label, + value: $alarm.predictiveMinutes + ) + AlarmActiveSection(alarm: $alarm) AlarmAudioSection(alarm: $alarm, hideRepeat: true) AlarmSnoozeSection(alarm: $alarm) diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/TempTargetEndAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/TempTargetEndAlarmEditor.swift index 03a7756f9..08072437f 100644 --- a/LoopFollow/Alarm/AlarmEditing/Editors/TempTargetEndAlarmEditor.swift +++ b/LoopFollow/Alarm/AlarmEditing/Editors/TempTargetEndAlarmEditor.swift @@ -8,10 +8,21 @@ struct TempTargetEndAlarmEditor: View { var body: some View { Group { - InfoBanner(text: "Alerts when a temp target ends.", alarmType: alarm.type) + InfoBanner(text: "Alerts when a temp target ends, with an optional early warning before the scheduled end.", alarmType: alarm.type) AlarmGeneralSection(alarm: $alarm) + AlarmStepperSection( + header: "Early Warning", + footer: "Also alert this many minutes before the temp target " + + "is scheduled to end. Set to 0 to alert only when it ends.", + title: "Warn before end", + range: 0 ... 30, + step: 5, + unitLabel: alarm.type.snoozeTimeUnit.label, + value: $alarm.predictiveMinutes + ) + AlarmActiveSection(alarm: $alarm) AlarmAudioSection(alarm: $alarm, hideRepeat: true) AlarmSnoozeSection(alarm: $alarm) diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index 29c6128d9..ea24bfb3b 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -133,9 +133,11 @@ class AlarmManager { } // Fire the alarm and break the loop; we only allow one alarm per evaluation tick. + let titleOverride = checker.notificationTitle(alarm: alarm, data: data, now: now) + Observable.shared.currentAlarmTitleOverride.value = titleOverride Observable.shared.currentAlarm.value = alarm.id - alarm.trigger(config: Storage.shared.alarmConfiguration.value, now: now) + alarm.trigger(config: Storage.shared.alarmConfiguration.value, now: now, titleOverride: titleOverride) // Store the latest bg time so we don't use it again if alarm.type.isBGBased, @@ -183,6 +185,7 @@ class AlarmManager { func stopAlarm() { AlarmSound.stop() Observable.shared.currentAlarm.value = nil + Observable.shared.currentAlarmTitleOverride.value = nil UNUserNotificationCenter.current().removeAllPendingNotificationRequests() } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift index b06de4446..326720a34 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift @@ -35,19 +35,26 @@ extension MainViewController { let start = max(startDate.timeIntervalSince1970, graphHorizon) + let isIndefinite = (e["durationType"] as? String) == "indefinite" // Only for Loop overrides var end: TimeInterval - if (e["durationType"] as? String) == "indefinite" { // Only for Loop overrides + if isIndefinite { end = maxEndDate } else { end = start + (e["duration"] as? Double ?? 5) * 60 } + // The scheduled end is kept unclamped so the end alarm's early + // warning can see ends beyond the graph horizon; nil when the + // override runs indefinitely. + var scheduledEnd: TimeInterval? = isIndefinite ? nil : end + if i + 1 < sorted.count, let nextDateStr = (sorted[i + 1]["timestamp"] as? String) ?? (sorted[i + 1]["created_at"] as? String), let nextStart = NightscoutUtils.parseDate(nextDateStr)? .timeIntervalSince1970 { end = min(end, nextStart - 60) // avoid overlapping overrides + scheduledEnd = scheduledEnd.map { min($0, nextStart - 60) } } end = min(end, maxEndDate) @@ -74,7 +81,8 @@ extension MainViewController { reason: (e["notes"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? (e["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", - sgv: -20 + sgv: -20, + scheduledEndDate: scheduledEnd ) overrideGraphData.append(dot) diff --git a/LoopFollow/Helpers/DataStructs.swift b/LoopFollow/Helpers/DataStructs.swift index 9cd8a175f..798731155 100644 --- a/LoopFollow/Helpers/DataStructs.swift +++ b/LoopFollow/Helpers/DataStructs.swift @@ -46,6 +46,10 @@ class DataStructs { var enteredBy: String var reason: String var sgv: Float + /// Scheduled end (start + duration); unlike `endDate` it is never + /// clamped to the graph horizon. Nil when the override runs + /// indefinitely, so the end is not known in advance. + var scheduledEndDate: TimeInterval? } struct tempTargetStruct: Codable { diff --git a/LoopFollow/Snoozer/SnoozerView.swift b/LoopFollow/Snoozer/SnoozerView.swift index f70daaf8c..e2475da4b 100644 --- a/LoopFollow/Snoozer/SnoozerView.swift +++ b/LoopFollow/Snoozer/SnoozerView.swift @@ -102,7 +102,7 @@ struct SnoozerView: View { if let alarm = vm.activeAlarm { VStack(spacing: 16) { // Alarm name at the top - Text(alarm.name) + Text(vm.alarmTitle) .font(.system(size: 30, weight: .semibold)) .foregroundColor(.white) .lineLimit(1) diff --git a/LoopFollow/Snoozer/SnoozerViewModel.swift b/LoopFollow/Snoozer/SnoozerViewModel.swift index bb6a3aa16..70b182c82 100644 --- a/LoopFollow/Snoozer/SnoozerViewModel.swift +++ b/LoopFollow/Snoozer/SnoozerViewModel.swift @@ -6,6 +6,7 @@ import Foundation final class SnoozerViewModel: ObservableObject { @Published var activeAlarm: Alarm? + @Published var alarmTitle: String = "" @Published var snoozeUnits: Int = 5 @Published var timeUnitLabel: String = "minutes" @@ -21,6 +22,7 @@ final class SnoozerViewModel: ObservableObject { .sink { [weak self] alarm in self?.activeAlarm = alarm if let a = alarm { + self?.alarmTitle = Observable.shared.currentAlarmTitleOverride.value ?? a.name self?.snoozeUnits = a.snoozeDuration self?.timeUnitLabel = a.type.snoozeTimeUnit.label } diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index b16a5994d..ccaa3e7c8 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -31,6 +31,9 @@ class Observable { var predictionColor = ObservableValue(default: .purple) var currentAlarm = ObservableValue(default: nil) + /// Replaces the current alarm's name on screen when the firing needs its + /// own wording (e.g. an end alarm's early warning); nil shows the name. + var currentAlarmTitleOverride = ObservableValue(default: nil) var alarmSoundPlaying = ObservableValue(default: false) var debug = ObservableValue(default: false) diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 4876924e2..1fe33a108 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -58,8 +58,10 @@ class Storage { var lastOverrideStartNotified = StorageValue(key: "lastOverrideStartNotified", defaultValue: nil) var lastOverrideEndNotified = StorageValue(key: "lastOverrideEndNotified", defaultValue: nil) + var lastOverridePreEndNotified = StorageValue(key: "lastOverridePreEndNotified", defaultValue: nil) var lastTempTargetStartNotified = StorageValue(key: "lastTempTargetStartNotified", defaultValue: nil) var lastTempTargetEndNotified = StorageValue(key: "lastTempTargetEndNotified", defaultValue: nil) + var lastTempTargetPreEndNotified = StorageValue(key: "lastTempTargetPreEndNotified", defaultValue: nil) var lastRecBolusNotified = StorageValue(key: "lastRecBolusNotified", defaultValue: nil) var lastCOBNotified = StorageValue(key: "lastCOBNotified", defaultValue: nil) var lastMissedBolusNotified = StorageValue(key: "lastMissedBolusNotified", defaultValue: nil) diff --git a/LoopFollow/Task/AlarmTask.swift b/LoopFollow/Task/AlarmTask.swift index c665c738d..c3c77b48a 100644 --- a/LoopFollow/Task/AlarmTask.swift +++ b/LoopFollow/Task/AlarmTask.swift @@ -19,6 +19,8 @@ extension MainViewController { let latestOverrideEnd = self.overrideGraphData.last { $0.endDate <= now }?.endDate let latestTempTargetStart = self.tempTargetGraphData.last { $0.date <= now }?.date let latestTempTargetEnd = self.tempTargetGraphData.last { $0.endDate <= now }?.endDate + let activeOverrideEnd = self.overrideGraphData.last { $0.date <= now && ($0.scheduledEndDate ?? 0) > now }?.scheduledEndDate + let activeTempTargetEnd = self.tempTargetGraphData.last { $0.date <= now && $0.endDate > now }?.endDate let recBolus = Observable.shared.deviceRecBolus.value let COB = self.latestCOB?.value let sensorInsertedAt = Storage.shared.sageInsertTime.value @@ -40,6 +42,8 @@ extension MainViewController { latestOverrideEnd: latestOverrideEnd, latestTempTargetStart: latestTempTargetStart, latestTempTargetEnd: latestTempTargetEnd, + activeOverrideEnd: activeOverrideEnd, + activeTempTargetEnd: activeTempTargetEnd, recBolus: recBolus, COB: COB, sageInsertTime: sensorInsertedAt, diff --git a/Tests/AlarmConditions/Helpers.swift b/Tests/AlarmConditions/Helpers.swift index b4287504e..f8ab4ef96 100644 --- a/Tests/AlarmConditions/Helpers.swift +++ b/Tests/AlarmConditions/Helpers.swift @@ -43,6 +43,18 @@ extension Alarm { alarm.monitoringWindow = window return alarm } + + static func tempTargetEnd(warnBefore: Int? = nil) -> Self { + var alarm = Alarm(type: .tempTargetEnd) + alarm.predictiveMinutes = warnBefore + return alarm + } + + static func overrideEnd(warnBefore: Int? = nil) -> Self { + var alarm = Alarm(type: .overrideEnd) + alarm.predictiveMinutes = warnBefore + return alarm + } } // MARK: - AlarmData helpers @@ -160,6 +172,62 @@ extension AlarmData { ) } + static func withTempTargetEnds(latestEnd: TimeInterval? = nil, activeEnd: TimeInterval? = nil) -> Self { + AlarmData( + bgReadings: [], + predictionData: [], + expireDate: nil, + lastLoopTime: nil, + latestOverrideStart: nil, + latestOverrideEnd: nil, + latestTempTargetStart: nil, + latestTempTargetEnd: latestEnd, + activeOverrideEnd: nil, + activeTempTargetEnd: activeEnd, + recBolus: nil, + COB: nil, + sageInsertTime: nil, + pumpInsertTime: nil, + latestPumpVolume: nil, + IOB: nil, + recentBoluses: [], + latestBattery: nil, + latestBatteryIsCharging: nil, + latestPumpBattery: nil, + batteryHistory: [], + recentCarbs: [], + dbSizePercentage: nil + ) + } + + static func withOverrideEnds(latestEnd: TimeInterval? = nil, activeEnd: TimeInterval? = nil) -> Self { + AlarmData( + bgReadings: [], + predictionData: [], + expireDate: nil, + lastLoopTime: nil, + latestOverrideStart: nil, + latestOverrideEnd: latestEnd, + latestTempTargetStart: nil, + latestTempTargetEnd: nil, + activeOverrideEnd: activeEnd, + activeTempTargetEnd: nil, + recBolus: nil, + COB: nil, + sageInsertTime: nil, + pumpInsertTime: nil, + latestPumpVolume: nil, + IOB: nil, + recentBoluses: [], + latestBattery: nil, + latestBatteryIsCharging: nil, + latestPumpBattery: nil, + batteryHistory: [], + recentCarbs: [], + dbSizePercentage: nil + ) + } + static func withCarbs(_ carbs: [CarbSample]) -> Self { AlarmData( bgReadings: [], diff --git a/Tests/AlarmConditions/OverrideEndConditionTests.swift b/Tests/AlarmConditions/OverrideEndConditionTests.swift new file mode 100644 index 000000000..4bf3f6f38 --- /dev/null +++ b/Tests/AlarmConditions/OverrideEndConditionTests.swift @@ -0,0 +1,118 @@ +// LoopFollow +// OverrideEndConditionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@Suite(.serialized) +struct OverrideEndConditionTests { + let cond = OverrideEndCondition() + + private func reset() { + Storage.shared.lastOverrideEndNotified.value = nil + Storage.shared.lastOverridePreEndNotified.value = nil + } + + // MARK: - End firing + + @Test("end firing fires once, then dedups") + func endFiresOnceThenDedups() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 - 60 + let alarm = Alarm.overrideEnd() + let data = AlarmData.withOverrideEnds(latestEnd: end) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("end firing ignores ends older than 15 minutes") + func endIgnoresStaleEnds() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 - 16 * 60 + let alarm = Alarm.overrideEnd() + let data = AlarmData.withOverrideEnds(latestEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + // MARK: - Early warning + + @Test("no early warning without a lead time") + func noEarlyWarningByDefault() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 4 * 60 + let data = AlarmData.withOverrideEnds(activeEnd: end) + + #expect(!cond.evaluate(alarm: .overrideEnd(), data: data, now: now)) + #expect(!cond.evaluate(alarm: .overrideEnd(warnBefore: 0), data: data, now: now)) + } + + @Test("no early warning when the end is unknown (indefinite override)") + func noEarlyWarningWithoutScheduledEnd() { + reset() + let now = Date() + let alarm = Alarm.overrideEnd(warnBefore: 10) + let data = AlarmData.withOverrideEnds(activeEnd: nil) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("early warning fires inside the lead window, once") + func earlyWarningFiresOnceInsideWindow() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 5 * 60 + let alarm = Alarm.overrideEnd(warnBefore: 10) + let data = AlarmData.withOverrideEnds(activeEnd: end) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("early warning does NOT fire before the lead window") + func earlyWarningWaitsForWindow() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 6 * 60 + let alarm = Alarm.overrideEnd(warnBefore: 5) + let data = AlarmData.withOverrideEnds(activeEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("early warning and end both fire for one event, with the right titles") + func bothPhasesFireWithTitles() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 4 * 60 + let alarm = Alarm.overrideEnd(warnBefore: 5) + + let preData = AlarmData.withOverrideEnds(activeEnd: end) + #expect(cond.evaluate(alarm: alarm, data: preData, now: now)) + #expect(cond.notificationTitle(alarm: alarm, data: preData, now: now) == "Override Ending Soon") + + let endNow = Date(timeIntervalSince1970: end + 60) + let endData = AlarmData.withOverrideEnds(latestEnd: end) + #expect(cond.evaluate(alarm: alarm, data: endData, now: endNow)) + #expect(cond.notificationTitle(alarm: alarm, data: endData, now: endNow) == nil) + } + + @Test("extending the override re-arms the early warning for the new end") + func extensionRearmsEarlyWarning() { + reset() + let now = Date() + let firstEnd = now.timeIntervalSince1970 + 4 * 60 + let alarm = Alarm.overrideEnd(warnBefore: 5) + + #expect(cond.evaluate(alarm: alarm, data: .withOverrideEnds(activeEnd: firstEnd), now: now)) + + let extendedEnd = firstEnd + 30 * 60 + let laterNow = Date(timeIntervalSince1970: extendedEnd - 4 * 60) + #expect(cond.evaluate(alarm: alarm, data: .withOverrideEnds(activeEnd: extendedEnd), now: laterNow)) + } +} diff --git a/Tests/AlarmConditions/TempTargetEndConditionTests.swift b/Tests/AlarmConditions/TempTargetEndConditionTests.swift new file mode 100644 index 000000000..93e50cb01 --- /dev/null +++ b/Tests/AlarmConditions/TempTargetEndConditionTests.swift @@ -0,0 +1,126 @@ +// LoopFollow +// TempTargetEndConditionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@Suite(.serialized) +struct TempTargetEndConditionTests { + let cond = TempTargetEndCondition() + + private func reset() { + Storage.shared.lastTempTargetEndNotified.value = nil + Storage.shared.lastTempTargetPreEndNotified.value = nil + } + + // MARK: - End firing + + @Test("end firing fires once, then dedups") + func endFiresOnceThenDedups() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 - 60 + let alarm = Alarm.tempTargetEnd() + let data = AlarmData.withTempTargetEnds(latestEnd: end) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("end firing ignores ends older than 15 minutes") + func endIgnoresStaleEnds() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 - 16 * 60 + let alarm = Alarm.tempTargetEnd() + let data = AlarmData.withTempTargetEnds(latestEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + // MARK: - Early warning + + @Test("no early warning without a lead time") + func noEarlyWarningByDefault() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 4 * 60 + let data = AlarmData.withTempTargetEnds(activeEnd: end) + + #expect(!cond.evaluate(alarm: .tempTargetEnd(), data: data, now: now)) + #expect(!cond.evaluate(alarm: .tempTargetEnd(warnBefore: 0), data: data, now: now)) + } + + @Test("early warning fires inside the lead window, once") + func earlyWarningFiresOnceInsideWindow() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 5 * 60 + let alarm = Alarm.tempTargetEnd(warnBefore: 10) + let data = AlarmData.withTempTargetEnds(activeEnd: end) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("early warning does NOT fire before the lead window") + func earlyWarningWaitsForWindow() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 6 * 60 + let alarm = Alarm.tempTargetEnd(warnBefore: 5) + let data = AlarmData.withTempTargetEnds(activeEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("early warning and end both fire for one event, with the right titles") + func bothPhasesFireWithTitles() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 4 * 60 + let alarm = Alarm.tempTargetEnd(warnBefore: 5) + + let preData = AlarmData.withTempTargetEnds(activeEnd: end) + #expect(cond.evaluate(alarm: alarm, data: preData, now: now)) + #expect(cond.notificationTitle(alarm: alarm, data: preData, now: now) == "Temp Target Ending Soon") + + let endNow = Date(timeIntervalSince1970: end + 60) + let endData = AlarmData.withTempTargetEnds(latestEnd: end) + #expect(cond.evaluate(alarm: alarm, data: endData, now: endNow)) + #expect(cond.notificationTitle(alarm: alarm, data: endData, now: endNow) == nil) + } + + @Test("extending the temp target re-arms the early warning for the new end") + func extensionRearmsEarlyWarning() { + reset() + let now = Date() + let firstEnd = now.timeIntervalSince1970 + 4 * 60 + let alarm = Alarm.tempTargetEnd(warnBefore: 5) + + #expect(cond.evaluate(alarm: alarm, data: .withTempTargetEnds(activeEnd: firstEnd), now: now)) + + let extendedEnd = firstEnd + 30 * 60 + let laterNow = Date(timeIntervalSince1970: extendedEnd - 4 * 60) + #expect(cond.evaluate(alarm: alarm, data: .withTempTargetEnds(activeEnd: extendedEnd), now: laterNow)) + } + + @Test("when both phases are due, the end fires first and the warning follows") + func endPhaseWinsSameTick() { + reset() + let now = Date() + let previousEnd = now.timeIntervalSince1970 - 60 + let activeEnd = now.timeIntervalSince1970 + 2 * 60 + let alarm = Alarm.tempTargetEnd(warnBefore: 5) + let data = AlarmData.withTempTargetEnds(latestEnd: previousEnd, activeEnd: activeEnd) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(cond.notificationTitle(alarm: alarm, data: data, now: now) == nil) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(cond.notificationTitle(alarm: alarm, data: data, now: now) == "Temp Target Ending Soon") + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } +} From b4d50fb69a7fc4fea6dbbdd3b3e51a26372df303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 18 Sep 2026 19:30:03 +0200 Subject: [PATCH 2/2] Report end alarm phases from a shared evaluator --- .../Alarm/AlarmCondition/AlarmCondition.swift | 8 +-- .../Alarm/AlarmCondition/EndAlarmPhases.swift | 47 ++++++++++++++++ .../AlarmCondition/OverrideEndCondition.swift | 49 +++++------------ .../TempTargetEndCondition.swift | 49 +++++------------ .../Editors/OverrideEndAlarmEditor.swift | 2 +- .../Editors/TempTargetEndAlarmEditor.swift | 2 +- LoopFollow/Alarm/AlarmManager.swift | 12 +++-- .../Treatments/TemporaryTarget.swift | 4 +- LoopFollow/Helpers/DataStructs.swift | 6 +++ LoopFollow/Snoozer/SnoozerViewModel.swift | 7 +++ LoopFollow/Task/AlarmTask.swift | 2 +- Tests/AlarmConditions/Helpers.swift | 49 +++++------------ .../OverrideEndConditionTests.swift | 49 ++++++++++++----- .../TempTargetEndConditionTests.swift | 53 ++++++++++++++----- 14 files changed, 195 insertions(+), 144 deletions(-) create mode 100644 LoopFollow/Alarm/AlarmCondition/EndAlarmPhases.swift diff --git a/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift b/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift index d8e90bba7..242d84069 100644 --- a/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift @@ -8,9 +8,9 @@ protocol AlarmCondition { init() /// pure, per-alarm logic against `AlarmData` func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool - /// Notification title for the firing that just evaluated true; nil (the - /// default) uses the alarm type's name. - func notificationTitle(alarm: Alarm, data: AlarmData, now: Date) -> String? + /// Title for the firing `evaluate` just reported; nil (the default) uses + /// the alarm type's name. + var firedTitle: String? { get } /// `true` when `belowBG`/`aboveBG` are this condition's own trigger /// threshold, checked in `evaluate`; `false` (the default) treats them /// as activation limits enforced by `passesBGLimits`. @@ -20,7 +20,7 @@ protocol AlarmCondition { extension AlarmCondition { var checksOwnBGLimits: Bool { false } - func notificationTitle(alarm _: Alarm, data _: AlarmData, now _: Date) -> String? { nil } + var firedTitle: String? { nil } /// Returns `true` when the alarm is allowed to continue evaluating /// after BG-limit checks; `false` blocks it immediately. diff --git a/LoopFollow/Alarm/AlarmCondition/EndAlarmPhases.swift b/LoopFollow/Alarm/AlarmCondition/EndAlarmPhases.swift new file mode 100644 index 000000000..db4dd32b9 --- /dev/null +++ b/LoopFollow/Alarm/AlarmCondition/EndAlarmPhases.swift @@ -0,0 +1,47 @@ +// LoopFollow +// EndAlarmPhases.swift + +import Foundation + +/// What an end alarm reports: the event has ended, or its scheduled end is +/// within the alarm's early-warning lead time. +enum EndAlarmPhase { + case ended + case endingSoon +} + +/// Two-phase evaluation shared by the temp target and override end alarms. +/// Each phase fires at most once per event; the markers hold the end +/// timestamp of the event that last fired that phase. +struct EndAlarmPhases { + /// Ends older than this are not reported. + static let endedGrace: TimeInterval = 15 * 60 + + let latestStart: TimeInterval? + let latestEnd: TimeInterval? + let activeEnd: TimeInterval? + let leadMinutes: Int? + let endedMarker: StorageValue + let warnedMarker: StorageValue + + /// The ended phase takes precedence when both are due in the same tick. + func fire(now: Date) -> EndAlarmPhase? { + let nowTS = now.timeIntervalSince1970 + + if let endTS = latestEnd, endTS > 0, + nowTS - endTS <= Self.endedGrace, + endTS > (endedMarker.value ?? 0) + { + endedMarker.value = endTS + return .ended + } + + guard let lead = leadMinutes, lead > 0, let endTS = activeEnd else { return nil } + let warningOpensAt = endTS - Double(lead) * 60 + // An event shorter than the lead time is covered by its start alarm. + if let startTS = latestStart, warningOpensAt <= startTS { return nil } + guard nowTS >= warningOpensAt, warnedMarker.value != endTS else { return nil } + warnedMarker.value = endTS + return .endingSoon + } +} diff --git a/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift b/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift index 0edbe188b..e10bae970 100644 --- a/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift @@ -3,44 +3,23 @@ import Foundation -/// Fires once when the active override ends and, if the alarm's -/// `predictiveMinutes` is set, once that many minutes before the scheduled end. -struct OverrideEndCondition: AlarmCondition { +/// Fires once when the active override ends and, with `predictiveMinutes` +/// set, once that many minutes before the scheduled end. +final class OverrideEndCondition: AlarmCondition { static let type: AlarmType = .overrideEnd + private(set) var firedTitle: String? init() {} func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool { - // The ended phase must stay ahead of the early-warning phase; - // notificationTitle(alarm:data:now:) relies on this ordering. - if let endTS = data.latestOverrideEnd, endTS > 0, - now.timeIntervalSince1970 - endTS <= 15 * 60 - { - let last = Storage.shared.lastOverrideEndNotified.value ?? 0 - if endTS > last { - Storage.shared.lastOverrideEndNotified.value = endTS - return true - } - } - - if let lead = alarm.predictiveMinutes, lead > 0, - let endTS = data.activeOverrideEnd, - now.timeIntervalSince1970 >= endTS - Double(lead) * 60 - { - let last = Storage.shared.lastOverridePreEndNotified.value ?? 0 - if endTS > last { - Storage.shared.lastOverridePreEndNotified.value = endTS - return true - } - } - - return false - } - - func notificationTitle(alarm _: Alarm, data: AlarmData, now: Date) -> String? { - guard let endTS = data.activeOverrideEnd, - now.timeIntervalSince1970 < endTS, - Storage.shared.lastOverridePreEndNotified.value == endTS - else { return nil } - return "Override Ending Soon" + let phase = EndAlarmPhases( + latestStart: data.latestOverrideStart, + latestEnd: data.latestOverrideEnd, + activeEnd: data.activeOverrideEnd, + leadMinutes: alarm.predictiveMinutes, + endedMarker: Storage.shared.lastOverrideEndNotified, + warnedMarker: Storage.shared.lastOverridePreEndNotified + ).fire(now: now) + firedTitle = phase == .endingSoon ? "Override Ending Soon" : nil + return phase != nil } } diff --git a/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift b/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift index 708976cf6..800129f96 100644 --- a/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift @@ -3,44 +3,23 @@ import Foundation -/// Fires once when the active temp target ends and, if the alarm's -/// `predictiveMinutes` is set, once that many minutes before the scheduled end. -struct TempTargetEndCondition: AlarmCondition { +/// Fires once when the active temp target ends and, with `predictiveMinutes` +/// set, once that many minutes before the scheduled end. +final class TempTargetEndCondition: AlarmCondition { static let type: AlarmType = .tempTargetEnd + private(set) var firedTitle: String? init() {} func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool { - // The ended phase must stay ahead of the early-warning phase; - // notificationTitle(alarm:data:now:) relies on this ordering. - if let endTS = data.latestTempTargetEnd, endTS > 0, - now.timeIntervalSince1970 - endTS <= 15 * 60 - { - let last = Storage.shared.lastTempTargetEndNotified.value ?? 0 - if endTS > last { - Storage.shared.lastTempTargetEndNotified.value = endTS - return true - } - } - - if let lead = alarm.predictiveMinutes, lead > 0, - let endTS = data.activeTempTargetEnd, - now.timeIntervalSince1970 >= endTS - Double(lead) * 60 - { - let last = Storage.shared.lastTempTargetPreEndNotified.value ?? 0 - if endTS > last { - Storage.shared.lastTempTargetPreEndNotified.value = endTS - return true - } - } - - return false - } - - func notificationTitle(alarm _: Alarm, data: AlarmData, now: Date) -> String? { - guard let endTS = data.activeTempTargetEnd, - now.timeIntervalSince1970 < endTS, - Storage.shared.lastTempTargetPreEndNotified.value == endTS - else { return nil } - return "Temp Target Ending Soon" + let phase = EndAlarmPhases( + latestStart: data.latestTempTargetStart, + latestEnd: data.latestTempTargetEnd, + activeEnd: data.activeTempTargetEnd, + leadMinutes: alarm.predictiveMinutes, + endedMarker: Storage.shared.lastTempTargetEndNotified, + warnedMarker: Storage.shared.lastTempTargetPreEndNotified + ).fire(now: now) + firedTitle = phase == .endingSoon ? "Temp Target Ending Soon" : nil + return phase != nil } } diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/OverrideEndAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/OverrideEndAlarmEditor.swift index adf465c83..d29a4a4e3 100644 --- a/LoopFollow/Alarm/AlarmEditing/Editors/OverrideEndAlarmEditor.swift +++ b/LoopFollow/Alarm/AlarmEditing/Editors/OverrideEndAlarmEditor.swift @@ -15,7 +15,7 @@ struct OverrideEndAlarmEditor: View { AlarmStepperSection( header: "Early Warning", footer: "Also alert this many minutes before the override " - + "is scheduled to end. Set to 0 to alert only when it ends.", + + "is scheduled to end. Set to 0 to alert only when it ends.", title: "Warn before end", range: 0 ... 30, step: 5, diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/TempTargetEndAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/TempTargetEndAlarmEditor.swift index 08072437f..b191da54f 100644 --- a/LoopFollow/Alarm/AlarmEditing/Editors/TempTargetEndAlarmEditor.swift +++ b/LoopFollow/Alarm/AlarmEditing/Editors/TempTargetEndAlarmEditor.swift @@ -15,7 +15,7 @@ struct TempTargetEndAlarmEditor: View { AlarmStepperSection( header: "Early Warning", footer: "Also alert this many minutes before the temp target " - + "is scheduled to end. Set to 0 to alert only when it ends.", + + "is scheduled to end. Set to 0 to alert only when it ends.", title: "Warn before end", range: 0 ... 30, step: 5, diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index ea24bfb3b..218c9659e 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -126,14 +126,20 @@ class AlarmManager { continue } - // If this alarm is active, and still fulfill the requirements, let it be active - // Break the loop, nothing else to do + let titleOverride = checker.firedTitle + + // An active alarm that still fulfills the requirements stays active. + // A different phase of it (an end alarm's early warning followed + // by the end itself) is announced with its own title. if Observable.shared.currentAlarm.value == alarm.id { + if titleOverride != Observable.shared.currentAlarmTitleOverride.value { + Observable.shared.currentAlarmTitleOverride.value = titleOverride + alarm.trigger(config: Storage.shared.alarmConfiguration.value, now: now, titleOverride: titleOverride) + } break } // Fire the alarm and break the loop; we only allow one alarm per evaluation tick. - let titleOverride = checker.notificationTitle(alarm: alarm, data: data, now: now) Observable.shared.currentAlarmTitleOverride.value = titleOverride Observable.shared.currentAlarm.value = alarm.id diff --git a/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift b/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift index 592e47f7a..c0d2e867b 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift @@ -60,9 +60,7 @@ extension MainViewController { let currentTime = Date().timeIntervalSince1970 if currentTime < endDate { activeTempTarget = Int(targetValue!) - // Trio represents indefinite temp targets as a ~30-day duration; - // treat a week or longer as indefinite (no end to display). - activeTempTargetEndAt = duration >= 7 * 24 * 3600 ? nil : endDate + activeTempTargetEndAt = dot.scheduledEndDate } } diff --git a/LoopFollow/Helpers/DataStructs.swift b/LoopFollow/Helpers/DataStructs.swift index 798731155..17e7cc506 100644 --- a/LoopFollow/Helpers/DataStructs.swift +++ b/LoopFollow/Helpers/DataStructs.swift @@ -59,5 +59,11 @@ class DataStructs { var correctionRange: [Int] var enteredBy: String var reason: String + + /// Scheduled end; nil for an indefinite target, which Trio stores + /// with a duration of about 30 days. + var scheduledEndDate: TimeInterval? { + duration >= 7 * 24 * 3600 ? nil : endDate + } } } diff --git a/LoopFollow/Snoozer/SnoozerViewModel.swift b/LoopFollow/Snoozer/SnoozerViewModel.swift index 70b182c82..7b806e52f 100644 --- a/LoopFollow/Snoozer/SnoozerViewModel.swift +++ b/LoopFollow/Snoozer/SnoozerViewModel.swift @@ -28,6 +28,13 @@ final class SnoozerViewModel: ObservableObject { } } .store(in: &cancellables) + Observable.shared.currentAlarmTitleOverride.$value + .receive(on: DispatchQueue.main) + .sink { [weak self] title in + guard let self, let alarm = self.activeAlarm else { return } + self.alarmTitle = title ?? alarm.name + } + .store(in: &cancellables) if let alarm = activeAlarm { snoozeUnits = alarm.snoozeDuration } diff --git a/LoopFollow/Task/AlarmTask.swift b/LoopFollow/Task/AlarmTask.swift index c3c77b48a..987a57427 100644 --- a/LoopFollow/Task/AlarmTask.swift +++ b/LoopFollow/Task/AlarmTask.swift @@ -20,7 +20,7 @@ extension MainViewController { let latestTempTargetStart = self.tempTargetGraphData.last { $0.date <= now }?.date let latestTempTargetEnd = self.tempTargetGraphData.last { $0.endDate <= now }?.endDate let activeOverrideEnd = self.overrideGraphData.last { $0.date <= now && ($0.scheduledEndDate ?? 0) > now }?.scheduledEndDate - let activeTempTargetEnd = self.tempTargetGraphData.last { $0.date <= now && $0.endDate > now }?.endDate + let activeTempTargetEnd = self.tempTargetGraphData.last { $0.date <= now && $0.endDate > now }?.scheduledEndDate let recBolus = Observable.shared.deviceRecBolus.value let COB = self.latestCOB?.value let sensorInsertedAt = Storage.shared.sageInsertTime.value diff --git a/Tests/AlarmConditions/Helpers.swift b/Tests/AlarmConditions/Helpers.swift index f8ab4ef96..2610dfb2b 100644 --- a/Tests/AlarmConditions/Helpers.swift +++ b/Tests/AlarmConditions/Helpers.swift @@ -172,46 +172,25 @@ extension AlarmData { ) } - static func withTempTargetEnds(latestEnd: TimeInterval? = nil, activeEnd: TimeInterval? = nil) -> Self { + static func withEnds( + latestTempTargetStart: TimeInterval? = nil, + latestTempTargetEnd: TimeInterval? = nil, + activeTempTargetEnd: TimeInterval? = nil, + latestOverrideStart: TimeInterval? = nil, + latestOverrideEnd: TimeInterval? = nil, + activeOverrideEnd: TimeInterval? = nil + ) -> Self { AlarmData( bgReadings: [], predictionData: [], expireDate: nil, lastLoopTime: nil, - latestOverrideStart: nil, - latestOverrideEnd: nil, - latestTempTargetStart: nil, - latestTempTargetEnd: latestEnd, - activeOverrideEnd: nil, - activeTempTargetEnd: activeEnd, - recBolus: nil, - COB: nil, - sageInsertTime: nil, - pumpInsertTime: nil, - latestPumpVolume: nil, - IOB: nil, - recentBoluses: [], - latestBattery: nil, - latestBatteryIsCharging: nil, - latestPumpBattery: nil, - batteryHistory: [], - recentCarbs: [], - dbSizePercentage: nil - ) - } - - static func withOverrideEnds(latestEnd: TimeInterval? = nil, activeEnd: TimeInterval? = nil) -> Self { - AlarmData( - bgReadings: [], - predictionData: [], - expireDate: nil, - lastLoopTime: nil, - latestOverrideStart: nil, - latestOverrideEnd: latestEnd, - latestTempTargetStart: nil, - latestTempTargetEnd: nil, - activeOverrideEnd: activeEnd, - activeTempTargetEnd: nil, + latestOverrideStart: latestOverrideStart, + latestOverrideEnd: latestOverrideEnd, + latestTempTargetStart: latestTempTargetStart, + latestTempTargetEnd: latestTempTargetEnd, + activeOverrideEnd: activeOverrideEnd, + activeTempTargetEnd: activeTempTargetEnd, recBolus: nil, COB: nil, sageInsertTime: nil, diff --git a/Tests/AlarmConditions/OverrideEndConditionTests.swift b/Tests/AlarmConditions/OverrideEndConditionTests.swift index 4bf3f6f38..dd8ac1175 100644 --- a/Tests/AlarmConditions/OverrideEndConditionTests.swift +++ b/Tests/AlarmConditions/OverrideEndConditionTests.swift @@ -22,7 +22,7 @@ struct OverrideEndConditionTests { let now = Date() let end = now.timeIntervalSince1970 - 60 let alarm = Alarm.overrideEnd() - let data = AlarmData.withOverrideEnds(latestEnd: end) + let data = AlarmData.withEnds(latestOverrideEnd: end) #expect(cond.evaluate(alarm: alarm, data: data, now: now)) #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) @@ -34,7 +34,7 @@ struct OverrideEndConditionTests { let now = Date() let end = now.timeIntervalSince1970 - 16 * 60 let alarm = Alarm.overrideEnd() - let data = AlarmData.withOverrideEnds(latestEnd: end) + let data = AlarmData.withEnds(latestOverrideEnd: end) #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) } @@ -46,7 +46,7 @@ struct OverrideEndConditionTests { reset() let now = Date() let end = now.timeIntervalSince1970 + 4 * 60 - let data = AlarmData.withOverrideEnds(activeEnd: end) + let data = AlarmData.withEnds(activeOverrideEnd: end) #expect(!cond.evaluate(alarm: .overrideEnd(), data: data, now: now)) #expect(!cond.evaluate(alarm: .overrideEnd(warnBefore: 0), data: data, now: now)) @@ -57,7 +57,7 @@ struct OverrideEndConditionTests { reset() let now = Date() let alarm = Alarm.overrideEnd(warnBefore: 10) - let data = AlarmData.withOverrideEnds(activeEnd: nil) + let data = AlarmData.withEnds(activeOverrideEnd: nil) #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) } @@ -68,7 +68,7 @@ struct OverrideEndConditionTests { let now = Date() let end = now.timeIntervalSince1970 + 5 * 60 let alarm = Alarm.overrideEnd(warnBefore: 10) - let data = AlarmData.withOverrideEnds(activeEnd: end) + let data = AlarmData.withEnds(activeOverrideEnd: end) #expect(cond.evaluate(alarm: alarm, data: data, now: now)) #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) @@ -80,7 +80,7 @@ struct OverrideEndConditionTests { let now = Date() let end = now.timeIntervalSince1970 + 6 * 60 let alarm = Alarm.overrideEnd(warnBefore: 5) - let data = AlarmData.withOverrideEnds(activeEnd: end) + let data = AlarmData.withEnds(activeOverrideEnd: end) #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) } @@ -92,14 +92,14 @@ struct OverrideEndConditionTests { let end = now.timeIntervalSince1970 + 4 * 60 let alarm = Alarm.overrideEnd(warnBefore: 5) - let preData = AlarmData.withOverrideEnds(activeEnd: end) + let preData = AlarmData.withEnds(activeOverrideEnd: end) #expect(cond.evaluate(alarm: alarm, data: preData, now: now)) - #expect(cond.notificationTitle(alarm: alarm, data: preData, now: now) == "Override Ending Soon") + #expect(cond.firedTitle == "Override Ending Soon") let endNow = Date(timeIntervalSince1970: end + 60) - let endData = AlarmData.withOverrideEnds(latestEnd: end) + let endData = AlarmData.withEnds(latestOverrideEnd: end) #expect(cond.evaluate(alarm: alarm, data: endData, now: endNow)) - #expect(cond.notificationTitle(alarm: alarm, data: endData, now: endNow) == nil) + #expect(cond.firedTitle == nil) } @Test("extending the override re-arms the early warning for the new end") @@ -109,10 +109,35 @@ struct OverrideEndConditionTests { let firstEnd = now.timeIntervalSince1970 + 4 * 60 let alarm = Alarm.overrideEnd(warnBefore: 5) - #expect(cond.evaluate(alarm: alarm, data: .withOverrideEnds(activeEnd: firstEnd), now: now)) + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeOverrideEnd: firstEnd), now: now)) let extendedEnd = firstEnd + 30 * 60 let laterNow = Date(timeIntervalSince1970: extendedEnd - 4 * 60) - #expect(cond.evaluate(alarm: alarm, data: .withOverrideEnds(activeEnd: extendedEnd), now: laterNow)) + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeOverrideEnd: extendedEnd), now: laterNow)) + } + + @Test("a replacement whose end is earlier than a warned end still warns") + func replacementWithEarlierEndWarns() { + reset() + let now = Date() + let alarm = Alarm.overrideEnd(warnBefore: 30) + let firstEnd = now.timeIntervalSince1970 + 25 * 60 + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeOverrideEnd: firstEnd), now: now)) + + let replacementEnd = now.timeIntervalSince1970 + 10 * 60 + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeOverrideEnd: replacementEnd), now: now)) + #expect(cond.firedTitle == "Override Ending Soon") + } + + @Test("no early warning when the lead time covers the whole event") + func noEarlyWarningForEventShorterThanLead() { + reset() + let now = Date() + let alarm = Alarm.overrideEnd(warnBefore: 30) + let start = now.timeIntervalSince1970 - 60 + let end = now.timeIntervalSince1970 + 20 * 60 + let data = AlarmData.withEnds(latestOverrideStart: start, activeOverrideEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) } } diff --git a/Tests/AlarmConditions/TempTargetEndConditionTests.swift b/Tests/AlarmConditions/TempTargetEndConditionTests.swift index 93e50cb01..9cc83f223 100644 --- a/Tests/AlarmConditions/TempTargetEndConditionTests.swift +++ b/Tests/AlarmConditions/TempTargetEndConditionTests.swift @@ -22,7 +22,7 @@ struct TempTargetEndConditionTests { let now = Date() let end = now.timeIntervalSince1970 - 60 let alarm = Alarm.tempTargetEnd() - let data = AlarmData.withTempTargetEnds(latestEnd: end) + let data = AlarmData.withEnds(latestTempTargetEnd: end) #expect(cond.evaluate(alarm: alarm, data: data, now: now)) #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) @@ -34,7 +34,7 @@ struct TempTargetEndConditionTests { let now = Date() let end = now.timeIntervalSince1970 - 16 * 60 let alarm = Alarm.tempTargetEnd() - let data = AlarmData.withTempTargetEnds(latestEnd: end) + let data = AlarmData.withEnds(latestTempTargetEnd: end) #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) } @@ -46,7 +46,7 @@ struct TempTargetEndConditionTests { reset() let now = Date() let end = now.timeIntervalSince1970 + 4 * 60 - let data = AlarmData.withTempTargetEnds(activeEnd: end) + let data = AlarmData.withEnds(activeTempTargetEnd: end) #expect(!cond.evaluate(alarm: .tempTargetEnd(), data: data, now: now)) #expect(!cond.evaluate(alarm: .tempTargetEnd(warnBefore: 0), data: data, now: now)) @@ -58,7 +58,7 @@ struct TempTargetEndConditionTests { let now = Date() let end = now.timeIntervalSince1970 + 5 * 60 let alarm = Alarm.tempTargetEnd(warnBefore: 10) - let data = AlarmData.withTempTargetEnds(activeEnd: end) + let data = AlarmData.withEnds(activeTempTargetEnd: end) #expect(cond.evaluate(alarm: alarm, data: data, now: now)) #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) @@ -70,7 +70,7 @@ struct TempTargetEndConditionTests { let now = Date() let end = now.timeIntervalSince1970 + 6 * 60 let alarm = Alarm.tempTargetEnd(warnBefore: 5) - let data = AlarmData.withTempTargetEnds(activeEnd: end) + let data = AlarmData.withEnds(activeTempTargetEnd: end) #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) } @@ -82,14 +82,14 @@ struct TempTargetEndConditionTests { let end = now.timeIntervalSince1970 + 4 * 60 let alarm = Alarm.tempTargetEnd(warnBefore: 5) - let preData = AlarmData.withTempTargetEnds(activeEnd: end) + let preData = AlarmData.withEnds(activeTempTargetEnd: end) #expect(cond.evaluate(alarm: alarm, data: preData, now: now)) - #expect(cond.notificationTitle(alarm: alarm, data: preData, now: now) == "Temp Target Ending Soon") + #expect(cond.firedTitle == "Temp Target Ending Soon") let endNow = Date(timeIntervalSince1970: end + 60) - let endData = AlarmData.withTempTargetEnds(latestEnd: end) + let endData = AlarmData.withEnds(latestTempTargetEnd: end) #expect(cond.evaluate(alarm: alarm, data: endData, now: endNow)) - #expect(cond.notificationTitle(alarm: alarm, data: endData, now: endNow) == nil) + #expect(cond.firedTitle == nil) } @Test("extending the temp target re-arms the early warning for the new end") @@ -99,11 +99,11 @@ struct TempTargetEndConditionTests { let firstEnd = now.timeIntervalSince1970 + 4 * 60 let alarm = Alarm.tempTargetEnd(warnBefore: 5) - #expect(cond.evaluate(alarm: alarm, data: .withTempTargetEnds(activeEnd: firstEnd), now: now)) + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeTempTargetEnd: firstEnd), now: now)) let extendedEnd = firstEnd + 30 * 60 let laterNow = Date(timeIntervalSince1970: extendedEnd - 4 * 60) - #expect(cond.evaluate(alarm: alarm, data: .withTempTargetEnds(activeEnd: extendedEnd), now: laterNow)) + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeTempTargetEnd: extendedEnd), now: laterNow)) } @Test("when both phases are due, the end fires first and the warning follows") @@ -113,13 +113,38 @@ struct TempTargetEndConditionTests { let previousEnd = now.timeIntervalSince1970 - 60 let activeEnd = now.timeIntervalSince1970 + 2 * 60 let alarm = Alarm.tempTargetEnd(warnBefore: 5) - let data = AlarmData.withTempTargetEnds(latestEnd: previousEnd, activeEnd: activeEnd) + let data = AlarmData.withEnds(latestTempTargetEnd: previousEnd, activeTempTargetEnd: activeEnd) #expect(cond.evaluate(alarm: alarm, data: data, now: now)) - #expect(cond.notificationTitle(alarm: alarm, data: data, now: now) == nil) + #expect(cond.firedTitle == nil) #expect(cond.evaluate(alarm: alarm, data: data, now: now)) - #expect(cond.notificationTitle(alarm: alarm, data: data, now: now) == "Temp Target Ending Soon") + #expect(cond.firedTitle == "Temp Target Ending Soon") + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("a replacement whose end is earlier than a warned end still warns") + func replacementWithEarlierEndWarns() { + reset() + let now = Date() + let alarm = Alarm.tempTargetEnd(warnBefore: 30) + let firstEnd = now.timeIntervalSince1970 + 25 * 60 + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeTempTargetEnd: firstEnd), now: now)) + + let replacementEnd = now.timeIntervalSince1970 + 10 * 60 + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeTempTargetEnd: replacementEnd), now: now)) + #expect(cond.firedTitle == "Temp Target Ending Soon") + } + + @Test("no early warning when the lead time covers the whole event") + func noEarlyWarningForEventShorterThanLead() { + reset() + let now = Date() + let alarm = Alarm.tempTargetEnd(warnBefore: 30) + let start = now.timeIntervalSince1970 - 60 + let end = now.timeIntervalSince1970 + 20 * 60 + let data = AlarmData.withEnds(latestTempTargetStart: start, activeTempTargetEnd: end) #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) }