From bdcc1e2fa4f05e5ae0a0a1349cc1c94d691732a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 28 Mar 2026 20:57:41 +0100 Subject: [PATCH] Add configurable CGM sensor lifetime for sensor change alarm The sensor change alarm was hardcoded to a 10-day lifetime, causing false alerts for users with 15-day G7 sensors. Add a per-alarm sensorLifetimeDays setting (7-15 days, default 10) so users can match their actual sensor duration. Also fix a bug where the sensor and pump change alarms would fire when no insert time was available (defaulting to epoch 0). --- LoopFollow/Alarm/Alarm.swift | 10 +++ .../AlarmCondition/PumpChangeCondition.swift | 2 +- .../AlarmCondition/SensorAgeCondition.swift | 11 ++- .../Editors/SensorAgeAlarmEditor.swift | 26 +++++- Tests/AlarmConditions/Helpers.swift | 32 ++++++++ .../SensorAgeConditionTests.swift | 82 +++++++++++++++++++ 6 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 Tests/AlarmConditions/SensorAgeConditionTests.swift diff --git a/LoopFollow/Alarm/Alarm.swift b/LoopFollow/Alarm/Alarm.swift index 6b6b37f6d..430a71a3e 100644 --- a/LoopFollow/Alarm/Alarm.swift +++ b/LoopFollow/Alarm/Alarm.swift @@ -107,6 +107,7 @@ struct Alarm: Identifiable, Codable, Equatable { case missedBolusPrebolusWindow, missedBolusIgnoreSmallBolusUnits case missedBolusIgnoreUnderGrams, missedBolusIgnoreUnderBG case bolusCountThreshold, bolusWindowMinutes + case sensorLifetimeDays } init(from decoder: Decoder) throws { @@ -137,6 +138,7 @@ struct Alarm: Identifiable, Codable, Equatable { missedBolusIgnoreUnderBG = try container.decodeIfPresent(Double.self, forKey: .missedBolusIgnoreUnderBG) bolusCountThreshold = try container.decodeIfPresent(Int.self, forKey: .bolusCountThreshold) bolusWindowMinutes = try container.decodeIfPresent(Int.self, forKey: .bolusWindowMinutes) + sensorLifetimeDays = try container.decodeIfPresent(Int.self, forKey: .sensorLifetimeDays) } func encode(to encoder: Encoder) throws { @@ -165,6 +167,7 @@ struct Alarm: Identifiable, Codable, Equatable { try container.encodeIfPresent(missedBolusIgnoreUnderBG, forKey: .missedBolusIgnoreUnderBG) try container.encodeIfPresent(bolusCountThreshold, forKey: .bolusCountThreshold) try container.encodeIfPresent(bolusWindowMinutes, forKey: .bolusWindowMinutes) + try container.encodeIfPresent(sensorLifetimeDays, forKey: .sensorLifetimeDays) } // ───────────────────────────────────────────────────────────── @@ -191,6 +194,12 @@ struct Alarm: Identifiable, Codable, Equatable { /// ...within this many minutes var bolusWindowMinutes: Int? + // ───────────────────────────────────────────────────────────── + // Sensor‑Change fields ─ + // ───────────────────────────────────────────────────────────── + /// CGM sensor lifetime in days (e.g. 10 for Dexcom G6, 15 for G7 15-day) + var sensorLifetimeDays: Int? + /// 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) { @@ -312,6 +321,7 @@ struct Alarm: Identifiable, Codable, Equatable { case .sensorChange: soundFile = .wakeUpWillYou threshold = 12 + sensorLifetimeDays = 10 case .pumpChange: soundFile = .wakeUpWillYou threshold = 12 diff --git a/LoopFollow/Alarm/AlarmCondition/PumpChangeCondition.swift b/LoopFollow/Alarm/AlarmCondition/PumpChangeCondition.swift index 6d86ee538..b9a8f7801 100644 --- a/LoopFollow/Alarm/AlarmCondition/PumpChangeCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/PumpChangeCondition.swift @@ -22,7 +22,7 @@ struct PumpChangeCondition: AlarmCondition { func evaluate(alarm: Alarm, data: AlarmData, now _: Date) -> Bool { // 0. sanity guards guard let warnAheadHrs = alarm.threshold, warnAheadHrs > 0 else { return false } - guard let insertTS = data.pumpInsertTime else { return false } + guard let insertTS = data.pumpInsertTime, insertTS > 0 else { return false } // convert UNIX timestamp → Date let insertedAt = Date(timeIntervalSince1970: insertTS) diff --git a/LoopFollow/Alarm/AlarmCondition/SensorAgeCondition.swift b/LoopFollow/Alarm/AlarmCondition/SensorAgeCondition.swift index 02abf94ba..5b563a9d0 100644 --- a/LoopFollow/Alarm/AlarmCondition/SensorAgeCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/SensorAgeCondition.swift @@ -4,23 +4,22 @@ import Foundation /// Fires once when we are **≤ threshold hours** away from the -/// Dexcom 10-day hard-stop. No repeats once triggered. +/// sensor's configured lifetime. No repeats once triggered. struct SensorAgeCondition: AlarmCondition { static let type: AlarmType = .sensorChange init() {} - /// Dexcom hard-stop = 10 days = 240 h - private let lifetime: TimeInterval = 10 * 24 * 60 * 60 - func evaluate(alarm: Alarm, data: AlarmData, now _: Date) -> Bool { // 0. basic guards guard let warnAheadHrs = alarm.threshold, warnAheadHrs > 0 else { return false } - guard let insertTS = data.sageInsertTime else { return false } + guard let insertTS = data.sageInsertTime, insertTS > 0 else { return false } // convert UNIX timestamp to Date let insertedAt = Date(timeIntervalSince1970: insertTS) - // 1. compute trigger moment + // 1. compute trigger moment using configurable lifetime (default 10 days) + let lifetimeDays = alarm.sensorLifetimeDays ?? 10 + let lifetime: TimeInterval = Double(lifetimeDays) * 24 * 60 * 60 let expiry = insertedAt.addingTimeInterval(lifetime) let trigger = expiry.addingTimeInterval(-warnAheadHrs * 3600) diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/SensorAgeAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/SensorAgeAlarmEditor.swift index a93c8dd5f..9f425de4c 100644 --- a/LoopFollow/Alarm/AlarmEditing/Editors/SensorAgeAlarmEditor.swift +++ b/LoopFollow/Alarm/AlarmEditing/Editors/SensorAgeAlarmEditor.swift @@ -6,18 +6,40 @@ import SwiftUI struct SensorAgeAlarmEditor: View { @Binding var alarm: Alarm + private var lifetimeDays: Int { + alarm.sensorLifetimeDays ?? 10 + } + + private var lifetimeBinding: Binding { + Binding( + get: { alarm.sensorLifetimeDays ?? 10 }, + set: { alarm.sensorLifetimeDays = $0 } + ) + } + var body: some View { Group { InfoBanner( - text: "Warn me this many hours before the sensor’s 10-day change-over.", + text: "Warn me before the sensor’s \(lifetimeDays)-day change-over.", alarmType: alarm.type ) AlarmGeneralSection(alarm: $alarm) + AlarmStepperSection( + header: "Sensor Lifetime", + footer: "Number of days your CGM sensor lasts " + + "(e.g. 10 for Dexcom G6, 15 for G7 15-day).", + title: "Lifetime", + range: 7 ... 15, + step: 1, + unitLabel: "days", + value: lifetimeBinding + ) + AlarmStepperSection( header: "Early Reminder", - footer: "Number of hours before the 10-day mark that the alert " + + footer: "Number of hours before the \(lifetimeDays)-day mark that the alert " + "will fire.", title: "Reminder Time", range: 1 ... 24, diff --git a/Tests/AlarmConditions/Helpers.swift b/Tests/AlarmConditions/Helpers.swift index 37220d12f..84497df1c 100644 --- a/Tests/AlarmConditions/Helpers.swift +++ b/Tests/AlarmConditions/Helpers.swift @@ -17,6 +17,13 @@ extension Alarm { alarm.threshold = threshold return alarm } + + static func sensorChange(threshold: Double?, lifetimeDays: Int? = 10) -> Self { + var alarm = Alarm(type: .sensorChange) + alarm.threshold = threshold + alarm.sensorLifetimeDays = lifetimeDays + return alarm + } } // MARK: - AlarmData helpers @@ -40,6 +47,31 @@ extension AlarmData { IOB: nil, recentBoluses: [], latestBattery: level, + latestPumpBattery: nil, + batteryHistory: [], + recentCarbs: [] + ) + } + + static func withSensorInsertTime(_ insertTime: TimeInterval?) -> Self { + AlarmData( + bgReadings: [], + predictionData: [], + expireDate: nil, + lastLoopTime: nil, + latestOverrideStart: nil, + latestOverrideEnd: nil, + latestTempTargetStart: nil, + latestTempTargetEnd: nil, + recBolus: nil, + COB: nil, + sageInsertTime: insertTime, + pumpInsertTime: nil, + latestPumpVolume: nil, + IOB: nil, + recentBoluses: [], + latestBattery: nil, + latestPumpBattery: nil, batteryHistory: [], recentCarbs: [] ) diff --git a/Tests/AlarmConditions/SensorAgeConditionTests.swift b/Tests/AlarmConditions/SensorAgeConditionTests.swift new file mode 100644 index 000000000..ec407a916 --- /dev/null +++ b/Tests/AlarmConditions/SensorAgeConditionTests.swift @@ -0,0 +1,82 @@ +// LoopFollow +// SensorAgeConditionTests.swift + +@testable import LoopFollow +import Testing + +struct SensorAgeConditionTests { + let cond = SensorAgeCondition() + + // MARK: - 10-day lifetime (default) + + @Test("fires when within threshold hours of 10-day expiry") + func firesNear10DayExpiry() { + let alarm = Alarm.sensorChange(threshold: 12, lifetimeDays: 10) + // Sensor inserted 9 days and 13 hours ago → 11 hours until 10-day mark → within 12-hour window + let insertTime = Date().addingTimeInterval(-9 * 86400 - 13 * 3600).timeIntervalSince1970 + let data = AlarmData.withSensorInsertTime(insertTime) + #expect(cond.evaluate(alarm: alarm, data: data, now: .init())) + } + + @Test("does NOT fire when far from 10-day expiry") + func doesNotFireEarly() { + let alarm = Alarm.sensorChange(threshold: 12, lifetimeDays: 10) + // Sensor inserted 8 days ago → 2 days until 10-day mark → outside 12-hour window + let insertTime = Date().addingTimeInterval(-8 * 86400).timeIntervalSince1970 + let data = AlarmData.withSensorInsertTime(insertTime) + #expect(!cond.evaluate(alarm: alarm, data: data, now: .init())) + } + + // MARK: - 15-day lifetime + + @Test("fires when within threshold hours of 15-day expiry") + func firesNear15DayExpiry() { + let alarm = Alarm.sensorChange(threshold: 12, lifetimeDays: 15) + // Sensor inserted 14 days and 13 hours ago → 11 hours until 15-day mark + let insertTime = Date().addingTimeInterval(-14 * 86400 - 13 * 3600).timeIntervalSince1970 + let data = AlarmData.withSensorInsertTime(insertTime) + #expect(cond.evaluate(alarm: alarm, data: data, now: .init())) + } + + @Test("does NOT fire at day 10 when lifetime is 15 days") + func doesNotFireAtDay10With15DayLifetime() { + let alarm = Alarm.sensorChange(threshold: 12, lifetimeDays: 15) + // Sensor inserted 10 days ago → 5 days until 15-day mark → should NOT fire + let insertTime = Date().addingTimeInterval(-10 * 86400).timeIntervalSince1970 + let data = AlarmData.withSensorInsertTime(insertTime) + #expect(!cond.evaluate(alarm: alarm, data: data, now: .init())) + } + + // MARK: - Edge cases + + @Test("does NOT fire when sageInsertTime is nil") + func ignoresMissingSensor() { + let alarm = Alarm.sensorChange(threshold: 12) + let data = AlarmData.withSensorInsertTime(nil) + #expect(!cond.evaluate(alarm: alarm, data: data, now: .init())) + } + + @Test("does NOT fire when sageInsertTime is zero (no sensor data)") + func ignoresZeroInsertTime() { + let alarm = Alarm.sensorChange(threshold: 12) + let data = AlarmData.withSensorInsertTime(0) + #expect(!cond.evaluate(alarm: alarm, data: data, now: .init())) + } + + @Test("does NOT fire when threshold is nil") + func ignoresNilThreshold() { + let alarm = Alarm.sensorChange(threshold: nil) + let insertTime = Date().addingTimeInterval(-11 * 86400).timeIntervalSince1970 + let data = AlarmData.withSensorInsertTime(insertTime) + #expect(!cond.evaluate(alarm: alarm, data: data, now: .init())) + } + + @Test("defaults to 10-day lifetime when sensorLifetimeDays is nil") + func defaultsTo10Days() { + let alarm = Alarm.sensorChange(threshold: 12, lifetimeDays: nil) + // Sensor inserted 9 days and 13 hours ago → within 12-hour window of 10-day mark + let insertTime = Date().addingTimeInterval(-9 * 86400 - 13 * 3600).timeIntervalSince1970 + let data = AlarmData.withSensorInsertTime(insertTime) + #expect(cond.evaluate(alarm: alarm, data: data, now: .init())) + } +}