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
10 changes: 10 additions & 0 deletions LoopFollow/Alarm/Alarm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}

// ─────────────────────────────────────────────────────────────
Expand All @@ -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) {
Expand Down Expand Up @@ -312,6 +321,7 @@ struct Alarm: Identifiable, Codable, Equatable {
case .sensorChange:
soundFile = .wakeUpWillYou
threshold = 12
sensorLifetimeDays = 10
case .pumpChange:
soundFile = .wakeUpWillYou
threshold = 12
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Alarm/AlarmCondition/PumpChangeCondition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 5 additions & 6 deletions LoopFollow/Alarm/AlarmCondition/SensorAgeCondition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
26 changes: 24 additions & 2 deletions LoopFollow/Alarm/AlarmEditing/Editors/SensorAgeAlarmEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,40 @@ import SwiftUI
struct SensorAgeAlarmEditor: View {
@Binding var alarm: Alarm

private var lifetimeDays: Int {
alarm.sensorLifetimeDays ?? 10
}

private var lifetimeBinding: Binding<Int?> {
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,
Expand Down
32 changes: 32 additions & 0 deletions Tests/AlarmConditions/Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: []
)
Expand Down
82 changes: 82 additions & 0 deletions Tests/AlarmConditions/SensorAgeConditionTests.swift
Original file line number Diff line number Diff line change
@@ -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()))
}
}