From 62855a39b15ba564e6e55f716e4702512587f1a2 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 16 Oct 2026 19:47:56 -0500 Subject: [PATCH] Daily analytics summary, settings state and device models Backport of LoopKit/Loop#2574, LoopKit/Loop#2578 and LoopKit/Loop#2582 from next-dev. - Replace the per-cycle "Loop success", "Loop error" and "CGM Fetch" events with one "Daily summary" per local day: loop success count and mean/max duration, loop error counts by issue id, the count of stored CGM readings, the day's UTC offset, and the settings state (closed loop, dosing strategy, integral retrospective correction, glucose based partial application). Running totals are kept in UserDefaults and the summary is sent on the first loop or CGM reading of the next day. - Send that settings state as user properties at launch and whenever it changes, with "Closed loop enabled change", "Dosing strategy change" and "Algorithm experiment change" events. Also send Pump Type and CGM Type at launch, and set them to "None" when the device is removed. - Send Pump Model and CGM Model user properties from the managers' HKDevice.model at setup and launch, and when a status update reports a different model. - Stop sending health data: drop bolus units and carb grams, and send only the kind of override, and its duration, not preset names, targets or sensitivity. - Send "Override Ended" when a preset is deactivated and "Settings Screen" when Settings opens. - Carb and bolus events from the phone are now in session; from the watch and remote commands they stay out of session. - Replace "Alert Issued" with "Alert Displayed": immediate alerts when issued, delayed and repeating alerts after a loop once they have come due without being retracted, and loop-not-running notifications when Loop infers their delivery. dev's glucose samples don't record backfill, so the summary counts CGM readings but has no backfill counts. The summary and state are updated on the main queue, since dev reports loops and readings from other queues. --- Loop/Managers/Alerts/AlertManager.swift | 23 +- Loop/Managers/Alerts/AlertStore.swift | 36 +++ Loop/Managers/AnalyticsServicesManager.swift | 212 +++++++++++++----- Loop/Managers/DeviceDataManager.swift | 20 +- Loop/Managers/LoopAppManager.swift | 18 +- Loop/Managers/ServicesManager.swift | 4 +- Loop/Managers/WatchDataManager.swift | 4 +- .../StatusTableViewController.swift | 1 + Loop/View Models/BolusEntryViewModel.swift | 4 +- .../Managers/Alerts/AlertStoreTests.swift | 27 +++ 10 files changed, 285 insertions(+), 64 deletions(-) diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index 50b99666e2..83f2a12d45 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -161,6 +161,24 @@ public final class AlertManager { func loopDidComplete(_ lastLoopDate: Date? = nil) { // use now if there is no lastLoopDate rescheduleLoopNotRunningNotifications(lastLoopDate ?? Date()) + reportDisplayedScheduledAlerts() + } + + private func reportDisplayedScheduledAlerts() { + let now = getCurrentDate() + let start = UserDefaults.standard.scheduledAlertsReportedThrough ?? now + UserDefaults.standard.scheduledAlertsReportedThrough = now + guard start < now else { + return + } + alertStore.lookupScheduledAlertsDue(after: start, through: now) { result in + guard case .success(let alerts) = result else { + return + } + for alert in alerts { + self.analyticsServicesManager.didDisplayAlert(identifier: alert.identifier.value, interruptionLevel: alert.interruptionLevel, scheduled: true) + } + } } private func rescheduleLoopNotRunningNotifications() { @@ -246,6 +264,7 @@ public final class AlertManager { let interruptionLevel: Alert.InterruptionLevel = notification.isCritical ? .critical : .timeSensitive let alert = Alert(identifier: alertIdentifier, foregroundContent: nil, backgroundContent: content, trigger: .immediate, interruptionLevel: interruptionLevel) recordIssued(alert: alert, at: notification.alertAt) + analyticsServicesManager.didDisplayAlert(identifier: alertIdentifier.value, interruptionLevel: interruptionLevel, scheduled: true) } else { stillPendingNotifications.append(notification) } @@ -361,7 +380,9 @@ extension AlertManager: AlertIssuer { deferredAlerts.append(alert) return } - analyticsServicesManager.didIssueAlert(identifier: alert.identifier.value, interruptionLevel: alert.interruptionLevel) + if alert.trigger == .immediate { + analyticsServicesManager.didDisplayAlert(identifier: alert.identifier.value, interruptionLevel: alert.interruptionLevel, scheduled: false) + } scheduleAlertWithSchedulers(alert) alertStore.recordIssued(alert: alert) } diff --git a/Loop/Managers/Alerts/AlertStore.swift b/Loop/Managers/Alerts/AlertStore.swift index d8d6db7e5c..702992adb8 100644 --- a/Loop/Managers/Alerts/AlertStore.swift +++ b/Loop/Managers/Alerts/AlertStore.swift @@ -210,6 +210,42 @@ public class AlertStore { } } + /// Delayed and repeating alerts that came due in (start, end] without being retracted first. + /// Only the latest issue of each identifier counts, since re-issuing replaces the scheduled alert. + public func lookupScheduledAlertsDue(after start: Date, through end: Date, completion: @escaping (Result<[(identifier: Alert.Identifier, interruptionLevel: Alert.InterruptionLevel)], Error>) -> Void) { + managedObjectContext.perform { + do { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSPredicate(format: "triggerType != %d", Alert.Trigger.immediate.storedType), + NSPredicate(format: "issuedDate > %@ AND issuedDate <= %@", end.addingTimeInterval(-.days(30)) as NSDate, end as NSDate) + ]) + fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "issuedDate", ascending: true) ] + + var latest: [Alert.Identifier: StoredAlert] = [:] + for alert in try self.managedObjectContext.fetch(fetchRequest) { + latest[alert.identifier] = alert + } + + completion(.success(latest.values.compactMap { alert in + guard let interval = alert.triggerInterval?.doubleValue else { + return nil + } + let due = alert.issuedDate.addingTimeInterval(interval) + guard due > start, due <= end else { + return nil + } + if let retractedDate = alert.retractedDate, retractedDate <= due { + return nil + } + return (alert.identifier, alert.interruptionLevel) + })) + } catch { + completion(.failure(error)) + } + } + } + public func lookupAllAcknowledgedUnretractedRepeatingAlerts(completion: @escaping (Result<[StoredAlert], Error>) -> Void) { managedObjectContext.perform { do { diff --git a/Loop/Managers/AnalyticsServicesManager.swift b/Loop/Managers/AnalyticsServicesManager.swift index 861489aba2..122de34575 100644 --- a/Loop/Managers/AnalyticsServicesManager.swift +++ b/Loop/Managers/AnalyticsServicesManager.swift @@ -17,6 +17,10 @@ final class AnalyticsServicesManager { private var analyticsServices = [AnalyticsService]() + var stateProvider: (() -> AnalyticsState)? + + private var lastState: AnalyticsState? + init() {} func addService(_ analyticsService: AnalyticsService) { @@ -70,6 +74,14 @@ final class AnalyticsServicesManager { identify("CGM Type", value: cgmType) } + func identifyPumpModel(_ model: String?) { + identify("Pump Model", value: model ?? "Unknown") + } + + func identifyCGMModel(_ model: String?) { + identify("CGM Model", value: model ?? "Unknown") + } + // MARK: - Screens func didDisplayBolusScreen() { @@ -139,10 +151,6 @@ final class AnalyticsServicesManager { logEvent("Minimum BG Guard change") } - if newValue.dosingEnabled != oldValue.dosingEnabled { - logEvent("Closed loop enabled change") - } - if newValue.basalRateSchedule?.timeZone != oldValue.basalRateSchedule?.timeZone { logEvent("Therapy schedule time zone change") } @@ -160,6 +168,8 @@ final class AnalyticsServicesManager { func pumpWasRemoved() { logEvent("Pump Removed") + identifyPumpType("None") + identifyPumpModel("None") } func pumpWasAdded(identifier: String) { @@ -168,72 +178,116 @@ final class AnalyticsServicesManager { func cgmWasRemoved() { logEvent("CGM Removed") + identifyCGMType("None") + identifyCGMModel("None") } func cgmWasAdded(identifier: String) { logEvent("CGM Added", withProperties: ["identifier" : identifier]) } - func didAddCarbs(source: String, amount: Double, inSession: Bool = false) { - logEvent("Carb entry created", withProperties: ["source" : source, "amount": "\(amount)"], outOfSession: inSession) + func didAddCarbs(source: String) { + logEvent("Carb entry created", withProperties: ["source" : source], outOfSession: source != "Phone") } func didRetryBolus() { logEvent("Bolus Retry") } - func didBolus(source: String, units: Double, inSession: Bool = false) { - logEvent("Bolus set", withProperties: ["source" : source, "units": "\(units)"], outOfSession: true) + func didBolus(source: String) { + logEvent("Bolus set", withProperties: ["source" : source], outOfSession: source != "Phone") } - func didFetchNewCGMData() { - logEvent("CGM Fetch", outOfSession: true) + // MARK: - Daily Summary + + func didStoreGlucose(_ samples: [StoredGlucoseSample]) { + let count = samples.filter { !$0.wasUserEntered }.count + updateDailySummary { $0.glucoseCount += count } } func loopDidSucceed(_ duration: TimeInterval) { - logEvent("Loop success", withProperties: ["duration": duration], outOfSession: true) + updateDailySummary { summary in + summary.loopSuccessCount += 1 + summary.totalLoopDuration += duration + summary.maxLoopDuration = max(summary.maxLoopDuration, duration) + } } func loopDidError(error: LoopError) { - var props = [AnyHashable: Any]() - - props["issueId"] = error.issueId + updateDailySummary { $0.loopErrorCounts[error.issueId, default: 0] += 1 } + } - for (detailKey, detail) in error.issueDetails { - props[detailKey] = detail + func identifyState() { + guard let state = stateProvider?() else { + return + } + identify("Closed Loop", value: state.closedLoop ? "On" : "Off") + identify("Dosing Strategy", value: state.dosingStrategy) + identify("Integral Retrospective Correction", value: state.integralRetrospectiveCorrection ? "On" : "Off") + identify("Glucose Based Partial Application", value: state.glucoseBasedPartialApplication ? "On" : "Off") + lastState = state + } + + // Callers are on the loop and CGM queues; the summary and state are only touched on main + private func updateDailySummary(_ update: @escaping (inout AnalyticsDailySummary) -> Void) { + let now = Date() + DispatchQueue.main.async { + let day = Calendar.current.startOfDay(for: now) + var summary = UserDefaults.standard.analyticsDailySummary ?? AnalyticsDailySummary(day: day) + + if summary.day != day { + self.logEvent("Daily summary", withProperties: summary.properties, outOfSession: true) + summary = AnalyticsDailySummary(day: day) + } + + update(&summary) + + if let state = self.stateProvider?() { + summary.state = state + if let lastState = self.lastState, state != lastState { + self.logStateChanges(from: lastState, to: state) + self.identifyState() + } + } + + UserDefaults.standard.analyticsDailySummary = summary } - - logEvent("Loop error", withProperties: props, outOfSession: true) } - func didIssueAlert(identifier: String, interruptionLevel: Alert.InterruptionLevel) { - logEvent("Alert Issued", withProperties: ["identifier": identifier, "interruptionLevel": interruptionLevel.rawValue]) + private func logStateChanges(from oldState: AnalyticsState, to newState: AnalyticsState) { + if newState.closedLoop != oldState.closedLoop { + logEvent("Closed loop enabled change", withProperties: ["enabled": newState.closedLoop]) + } + if newState.dosingStrategy != oldState.dosingStrategy { + logEvent("Dosing strategy change", withProperties: ["value": newState.dosingStrategy]) + } + if newState.integralRetrospectiveCorrection != oldState.integralRetrospectiveCorrection { + logEvent("Algorithm experiment change", withProperties: ["experiment": "Integral Retrospective Correction", "enabled": newState.integralRetrospectiveCorrection]) + } + if newState.glucoseBasedPartialApplication != oldState.glucoseBasedPartialApplication { + logEvent("Algorithm experiment change", withProperties: ["experiment": "Glucose Based Partial Application", "enabled": newState.glucoseBasedPartialApplication]) + } } - func didEnactOverride(name: String, symbol: String, duration: TemporaryScheduleOverride.Duration, insulinSensitivityMultiplier: Double = 1.0, targetRange: ClosedRange? = nil) - { - let combinedName = "\(symbol) - \(name)" + func didDisplayAlert(identifier: String, interruptionLevel: Alert.InterruptionLevel, scheduled: Bool) { + logEvent("Alert Displayed", withProperties: ["identifier": identifier, "interruptionLevel": interruptionLevel.rawValue, "scheduled": scheduled], outOfSession: scheduled) + } - var properties: [String: Any] = [ - "name": name, - "symbol": symbol, - "sensitivityMultiplier": insulinSensitivityMultiplier, - "nameWithEmoji": combinedName - ] + func didEnactOverride(kind: String, duration: TemporaryScheduleOverride.Duration) { + var properties: [String: Any] = ["kind": kind] - if let targetUpperBound = targetRange?.upperBound.doubleValue(for: HKUnit.milligramsPerDeciliter) { - properties["targetUpperBound"] = targetUpperBound - } - if let targetLowerBound = targetRange?.lowerBound.doubleValue(for: HKUnit.milligramsPerDeciliter) { - properties["targetLowerBound"] = targetLowerBound + switch duration { + case .finite(let interval): + properties["durationMinutes"] = Int(interval.minutes) + case .indefinite: + properties["durationMinutes"] = "indefinite" } - logEvent("Override Enacted", withProperties: properties) } - func didCancelOverride(name: String) { - logEvent("Override Canceled", withProperties: ["name": name]) + func didEndOverride(kind: String) { + logEvent("Override Ended", withProperties: ["kind": kind]) } } @@ -241,24 +295,78 @@ final class AnalyticsServicesManager { // MARK: - PresetActivationObserver extension AnalyticsServicesManager: PresetActivationObserver { func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) { - switch context { - case .legacyWorkout: - didEnactOverride(name: "workout", symbol: "", duration: duration) - case .preMeal: - didEnactOverride(name: "preMeal", symbol: "", duration: duration) - case .custom: - didEnactOverride(name: "custom", symbol: "", duration: duration) - case .preset(let preset): - didEnactOverride(name: preset.name, symbol: preset.symbol, duration: duration, insulinSensitivityMultiplier: preset.settings.effectiveInsulinNeedsScaleFactor, targetRange: preset.settings.targetRange) - } + didEnactOverride(kind: context.analyticsKind, duration: duration) } func presetDeactivated(context: TemporaryScheduleOverride.Context) { - switch context { - case .legacyWorkout: - break - default: - break + didEndOverride(kind: context.analyticsKind) + } +} + +extension TemporaryScheduleOverride.Context { + var analyticsKind: String { + switch self { + case .legacyWorkout: return "workout" + case .preMeal: return "preMeal" + case .custom: return "custom" + case .preset: return "preset" + } + } +} + +struct AnalyticsState: Codable, Equatable { + var closedLoop: Bool + var dosingStrategy: String + var integralRetrospectiveCorrection: Bool + var glucoseBasedPartialApplication: Bool +} + +struct AnalyticsDailySummary: Codable, Equatable { + var day: Date + var utcOffset = TimeZone.current.secondsFromGMT() + var loopSuccessCount = 0 + var totalLoopDuration: TimeInterval = 0 + var maxLoopDuration: TimeInterval = 0 + var loopErrorCounts: [String: Int] = [:] + var glucoseCount = 0 + var state: AnalyticsState? + + var properties: [AnyHashable: Any] { + var properties: [AnyHashable: Any] = [ + "day": ISO8601DateFormatter.string(from: day, timeZone: TimeZone(secondsFromGMT: utcOffset) ?? .current, formatOptions: .withFullDate), + "utcOffset": utcOffset, + "loopSuccessCount": loopSuccessCount, + "meanLoopDuration": loopSuccessCount > 0 ? totalLoopDuration / Double(loopSuccessCount) : 0, + "maxLoopDuration": maxLoopDuration, + "loopErrorCount": loopErrorCounts.values.reduce(0, +), + "loopErrorCounts": loopErrorCounts, + "glucoseCount": glucoseCount + ] + if let state { + properties["closedLoop"] = state.closedLoop + properties["dosingStrategy"] = state.dosingStrategy + properties["integralRetrospectiveCorrection"] = state.integralRetrospectiveCorrection + properties["glucoseBasedPartialApplication"] = state.glucoseBasedPartialApplication + } + return properties + } +} + +extension UserDefaults { + private static let analyticsDailySummaryKey = "com.loopkit.Loop.AnalyticsDailySummary" + private static let scheduledAlertsReportedThroughKey = "com.loopkit.Loop.AnalyticsScheduledAlertsReportedThrough" + + var scheduledAlertsReportedThrough: Date? { + get { object(forKey: Self.scheduledAlertsReportedThroughKey) as? Date } + set { set(newValue, forKey: Self.scheduledAlertsReportedThroughKey) } + } + + var analyticsDailySummary: AnalyticsDailySummary? { + get { + data(forKey: Self.analyticsDailySummaryKey).flatMap { try? JSONDecoder().decode(AnalyticsDailySummary.self, from: $0) } + } + set { + set(newValue.flatMap { try? JSONEncoder().encode($0) }, forKey: Self.analyticsDailySummaryKey) } } } diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 09ee0c11db..8c2b705c0b 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -43,6 +43,8 @@ final class DeviceDataManager { @Published var cgmHasValidSensorSession: Bool + private var cgmDeviceModel: String? + @Published var pumpIsAllowingAutomation: Bool private var lastCGMLoopTrigger: Date = .distantPast @@ -581,6 +583,9 @@ final class DeviceDataManager { switch readingResult { case .newData(let values): loopManager.addGlucoseSamples(values) { result in + if case .success(let samples) = result { + self.analyticsServicesManager.didStoreGlucose(samples) + } if !values.isEmpty { DispatchQueue.main.async { self.cgmStalenessMonitor.cgmGlucoseSamplesAvailable(values) @@ -733,6 +738,8 @@ private extension DeviceDataManager { cgmHasValidSensorSession = cgmManager.cgmManagerStatus.hasValidSensorSession analyticsServicesManager.identifyCGMType(cgmManager.pluginIdentifier) + cgmDeviceModel = cgmManager.cgmManagerStatus.device?.model + analyticsServicesManager.identifyCGMModel(cgmDeviceModel) } if let cgmManagerUI = cgmManager as? CGMManagerUI { @@ -763,6 +770,7 @@ private extension DeviceDataManager { deliveryUncertaintyAlertManager = DeliveryUncertaintyAlertManager(pumpManager: pumpManager, alertPresenter: alertPresenter) analyticsServicesManager.identifyPumpType(pumpManager.pluginIdentifier) + analyticsServicesManager.identifyPumpModel(pumpManager.status.device.model) updatePumpManagerBLEHeartbeatPreference() } @@ -1055,6 +1063,10 @@ extension DeviceDataManager: CGMManagerDelegate { if self.cgmHasValidSensorSession != status.hasValidSensorSession { self.cgmHasValidSensorSession = status.hasValidSensorSession } + if self.cgmDeviceModel != status.device?.model { + self.cgmDeviceModel = status.device?.model + self.analyticsServicesManager.identifyCGMModel(self.cgmDeviceModel) + } } } } @@ -1112,10 +1124,6 @@ extension DeviceDataManager: PumpManagerDelegate { } cgmManager.fetchNewDataIfNeeded { (result) in - if case .newData = result { - self.analyticsServicesManager.didFetchNewCGMData() - } - self.queue.async { self.processCGMReadingResult(cgmManager, readingResult: result) { if self.loopManager.lastLoopCompleted == nil || self.loopManager.lastLoopCompleted!.timeIntervalSinceNow < -.minutes(4.2) { @@ -1157,6 +1165,10 @@ extension DeviceDataManager: PumpManagerDelegate { log.default("PumpManager:%{public}@ did update status: %{public}@", String(describing: type(of: pumpManager)), String(describing: status)) doseStore.device = status.device + + if status.device.model != oldStatus.device.model { + analyticsServicesManager.identifyPumpModel(status.device.model) + } if let newBatteryValue = status.pumpBatteryChargeRemaining, let oldBatteryValue = oldStatus.pumpBatteryChargeRemaining, diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 3971e8c8c8..b9a36d1295 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -260,7 +260,23 @@ class LoopAppManager: NSObject { analyticsServicesManager.identifyWorkspaceGitRevision(workspaceGitRevision) } - analyticsServicesManager.identify("Dosing Strategy", value: settingsManager.loopSettings.automaticDosingStrategy.analyticsValue) + analyticsServicesManager.stateProvider = { [settingsManager = settingsManager!] in + AnalyticsState( + closedLoop: settingsManager.loopSettings.dosingEnabled, + dosingStrategy: settingsManager.loopSettings.automaticDosingStrategy.analyticsValue, + integralRetrospectiveCorrection: UserDefaults.standard.integralRetrospectiveCorrectionEnabled, + glucoseBasedPartialApplication: UserDefaults.standard.glucoseBasedApplicationFactorEnabled + ) + } + analyticsServicesManager.identifyState() + if let pumpManager = deviceDataManager.pumpManager { + analyticsServicesManager.identifyPumpType(pumpManager.pluginIdentifier) + analyticsServicesManager.identifyPumpModel(pumpManager.status.device.model) + } + if let cgmManager = deviceDataManager.cgmManager { + analyticsServicesManager.identifyCGMType(cgmManager.pluginIdentifier) + analyticsServicesManager.identifyCGMModel(cgmManager.cgmManagerStatus.device?.model) + } let serviceNames = deviceDataManager.servicesManager.activeServices.map { $0.pluginIdentifier } analyticsServicesManager.identify("Services", array: serviceNames) diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index 2393ceb073..3fb788f9da 100644 --- a/Loop/Managers/ServicesManager.swift +++ b/Loop/Managers/ServicesManager.swift @@ -322,7 +322,7 @@ extension ServicesManager: ServiceDelegate { try await servicesManagerDelegate?.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) await NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) await remoteDataServicesManager.triggerUpload(for: .carb) - analyticsServicesManager.didAddCarbs(source: "Remote", amount: amountInGrams) + analyticsServicesManager.didAddCarbs(source: "Remote") } catch { await NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: amountInGrams) throw error @@ -347,7 +347,7 @@ extension ServicesManager: ServiceDelegate { try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits) await NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) await remoteDataServicesManager.triggerUpload(for: .dose) - analyticsServicesManager.didBolus(source: "Remote", units: amountInUnits) + analyticsServicesManager.didBolus(source: "Remote") } catch { await NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) throw error diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index bac60b71dc..b2f8c8db81 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -386,7 +386,7 @@ final class WatchDataManager: NSObject { deviceManager.enactBolus(units: bolus.value, activationType: bolus.activationType) { (error) in if error == nil { - self.deviceManager.analyticsServicesManager.didBolus(source: "Watch", units: bolus.value) + self.deviceManager.analyticsServicesManager.didBolus(source: "Watch") } // When we've successfully started the bolus, send a new context with our new prediction @@ -401,7 +401,7 @@ final class WatchDataManager: NSObject { switch result { case .success(let storedCarbEntry): dosingDecision.carbEntry = storedCarbEntry - self.deviceManager.analyticsServicesManager.didAddCarbs(source: "Watch", amount: storedCarbEntry.quantity.doubleValue(for: .gram())) + self.deviceManager.analyticsServicesManager.didAddCarbs(source: "Watch") enactBolus() case .failure(let error): self.log.error("%{public}@", String(describing: error)) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 3e124fa7f5..2e81f1ad52 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1581,6 +1581,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } private func presentSettings() { + deviceManager.analyticsServicesManager.didDisplaySettingsScreen() let deletePumpDataFunc: () -> PumpManagerViewModel.DeleteTestingDataFunc? = { [weak self] in (self?.deviceManager.pumpManager is TestingPumpManager) ? { [weak self] in self?.deviceManager.deleteTestingPumpData() diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index a86f20e0cc..20735e7fd7 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -407,7 +407,7 @@ final class BolusEntryViewModel: ObservableObject { } if let storedCarbEntry = await saveCarbEntry(carbEntry, replacingEntry: originalCarbEntry) { self.dosingDecision.carbEntry = storedCarbEntry - self.analyticsServicesManager?.didAddCarbs(source: "Phone", amount: storedCarbEntry.quantity.doubleValue(for: .gram())) + self.analyticsServicesManager?.didAddCarbs(source: "Phone") } else { self.presentAlert(.carbEntryPersistenceFailure) return false @@ -422,7 +422,7 @@ final class BolusEntryViewModel: ObservableObject { if amountToDeliver > 0 { savedPreMealOverride = nil delegate.enactBolus(units: amountToDeliver, activationType: activationType, completion: { _ in - self.analyticsServicesManager?.didBolus(source: "Phone", units: amountToDeliver) + self.analyticsServicesManager?.didBolus(source: "Phone") }) } return true diff --git a/LoopTests/Managers/Alerts/AlertStoreTests.swift b/LoopTests/Managers/Alerts/AlertStoreTests.swift index bb9d109633..e7419b6693 100644 --- a/LoopTests/Managers/Alerts/AlertStoreTests.swift +++ b/LoopTests/Managers/Alerts/AlertStoreTests.swift @@ -91,6 +91,33 @@ class AlertStoreTests: XCTestCase { XCTAssertEqual(999, newAnchor?.modificationCounter) } + func testLookupScheduledAlertsDue() { + let issued = Self.historicDate + let due = issued.addingTimeInterval(Self.delayedAlertDelay) + let expect = self.expectation(description: #function) + alertStore.recordIssued(alert: alert1, at: issued, completion: self.expectSuccess { + self.alertStore.recordIssued(alert: self.delayedAlert, at: issued, completion: self.expectSuccess { + self.alertStore.recordIssued(alert: self.repeatingAlert, at: issued, completion: self.expectSuccess { + self.alertStore.recordRetraction(of: Self.repeatingAlertIdentifier, at: due.addingTimeInterval(-1), completion: self.expectSuccess { + self.alertStore.lookupScheduledAlertsDue(after: issued, through: due.addingTimeInterval(1), completion: self.expectSuccess { dueAlerts in + XCTAssertEqual([Self.delayedAlertIdentifier], dueAlerts.map { $0.identifier }) + self.alertStore.lookupScheduledAlertsDue(after: issued, through: due.addingTimeInterval(-1), completion: self.expectSuccess { dueAlerts in + XCTAssertTrue(dueAlerts.isEmpty) + self.alertStore.recordIssued(alert: self.delayedAlert, at: issued.addingTimeInterval(10), completion: self.expectSuccess { + self.alertStore.lookupScheduledAlertsDue(after: issued, through: due.addingTimeInterval(1), completion: self.expectSuccess { dueAlerts in + XCTAssertTrue(dueAlerts.isEmpty) + expect.fulfill() + }) + }) + }) + }) + }) + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + func testRecordIssued() { let expect = self.expectation(description: #function) alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess {