From 22c799cd88186ad936fa19028025ae51f5a18664 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 13 Oct 2026 01:40:04 -0500 Subject: [PATCH 1/3] Daily analytics summary, settings state, and no health data in events - 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, CGM fetch count, 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; they were only sent when a device was set up. - Stop sending health data: drop bolus units and carb grams, and send only the kind of override (preMeal, custom, preset, activity), its duration and the activity type, not preset names, targets or sensitivity. - Send "Override Ended" when a preset is deactivated, "Settings Screen" when Settings opens, and the loop settings change events, none of which had callers. - Carb and bolus events from the phone are now in session; from the watch and remote commands they stay out of session. The inSession parameters were ignored or inverted. --- Loop/Managers/AnalyticsServicesManager.swift | 196 +++++++++++++----- Loop/Managers/LoopAppManager.swift | 16 +- Loop/Managers/ServicesManager.swift | 4 +- Loop/Managers/SettingsManager.swift | 2 + Loop/Managers/WatchDataManager.swift | 4 +- .../StatusTableViewController.swift | 1 + Loop/View Models/BolusEntryViewModel.swift | 4 +- 7 files changed, 168 insertions(+), 59 deletions(-) diff --git a/Loop/Managers/AnalyticsServicesManager.swift b/Loop/Managers/AnalyticsServicesManager.swift index 7fb76247aa..368e667b90 100644 --- a/Loop/Managers/AnalyticsServicesManager.swift +++ b/Loop/Managers/AnalyticsServicesManager.swift @@ -17,6 +17,10 @@ final class AnalyticsServicesManager { private var analyticsServices = [AnalyticsService]() + var stateProvider: (@MainActor () -> AnalyticsState)? + + private var lastState: AnalyticsState? + init() {} func addService(_ analyticsService: AnalyticsService) { @@ -139,10 +143,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") } @@ -170,66 +170,110 @@ final class AnalyticsServicesManager { logEvent("CGM Added", withProperties: ["identifier" : identifier]) } - func didAddCarbs(source: String, amount: Double, isFavoriteFood: Bool = false, inSession: Bool = false) { - logEvent("Carb entry created", withProperties: ["source" : source, "amount": "\(amount)", "isFavoriteFood": isFavoriteFood], outOfSession: inSession) + func didAddCarbs(source: String, isFavoriteFood: Bool = false) { + logEvent("Carb entry created", withProperties: ["source" : source, "isFavoriteFood": isFavoriteFood], 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") } + // MARK: - Daily Summary + + @MainActor func didFetchNewCGMData() { - logEvent("CGM Fetch", outOfSession: true) + updateDailySummary { $0.cgmFetchCount += 1 } } + @MainActor 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) + } } + @MainActor func loopDidError(error: LoopError) { - var props = [AnyHashable: Any]() + updateDailySummary { $0.loopErrorCounts[error.issueId, default: 0] += 1 } + } - props["issueId"] = error.issueId + @MainActor + 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 + } - for (detailKey, detail) in error.issueDetails { - props[detailKey] = detail + @MainActor + private func updateDailySummary(_ update: (inout AnalyticsDailySummary) -> Void, now: Date = Date()) { + let day = Calendar.current.startOfDay(for: now) + var summary = UserDefaults.standard.analyticsDailySummary ?? AnalyticsDailySummary(day: day) + + if summary.day != day { + logEvent("Daily summary", withProperties: summary.properties, outOfSession: true) + summary = AnalyticsDailySummary(day: day) } - logEvent("Loop error", withProperties: props, outOfSession: true) + update(&summary) + + if let state = stateProvider?() { + summary.state = state + if let lastState, state != lastState { + logStateChanges(from: lastState, to: state) + identifyState() + } + } + + UserDefaults.standard.analyticsDailySummary = summary + } + + 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 didIssueAlert(identifier: String, interruptionLevel: Alert.InterruptionLevel) { logEvent("Alert Issued", withProperties: ["identifier": identifier, "interruptionLevel": interruptionLevel.rawValue]) } - func didEnactOverride(name: String, symbol: String, duration: TemporaryScheduleOverride.Duration, insulinSensitivityMultiplier: Double = 1.0, targetRange: ClosedRange? = nil) - { - let combinedName = "\(symbol) - \(name)" - - var properties: [String: Any] = [ - "name": name, - "symbol": symbol, - "sensitivityMultiplier": insulinSensitivityMultiplier, - "nameWithEmoji": combinedName - ] + func didEnactOverride(kind: String, duration: TemporaryScheduleOverride.Duration, activityType: String? = nil) { + var properties: [String: Any] = ["kind": kind] - if let targetUpperBound = targetRange?.upperBound.doubleValue(for: LoopUnit.milligramsPerDeciliter) { - properties["targetUpperBound"] = targetUpperBound - } - if let targetLowerBound = targetRange?.lowerBound.doubleValue(for: LoopUnit.milligramsPerDeciliter) { - properties["targetLowerBound"] = targetLowerBound + switch duration { + case .finite(let interval): + properties["durationMinutes"] = Int(interval.minutes) + case .indefinite: + properties["durationMinutes"] = "indefinite" } + properties["activityType"] = activityType 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]) } } @@ -238,30 +282,78 @@ final class AnalyticsServicesManager { extension AnalyticsServicesManager: PresetActivationObserver { func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) { switch context { - 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?.textualRepresentation ?? "", - duration: duration, - insulinSensitivityMultiplier: preset.settings.effectiveInsulinNeedsScaleFactor, - targetRange: preset.settings.targetRange - ) case .activity(let activity): - didEnactOverride( - name: activity.activityType.name, - symbol: activity.preset.symbol?.textualRepresentation ?? "", - duration: activity.preset.duration, - insulinSensitivityMultiplier: activity.preset.settings.effectiveInsulinNeedsScaleFactor, - targetRange: activity.preset.settings.targetRange - ) + didEnactOverride(kind: context.analyticsKind, duration: activity.preset.duration, activityType: activity.activityType.rawValue) + default: + didEnactOverride(kind: context.analyticsKind, duration: duration) + } + } + + func presetDeactivated(context: TemporaryScheduleOverride.Context) { + didEndOverride(kind: context.analyticsKind) + } +} + +extension TemporaryScheduleOverride.Context { + var analyticsKind: String { + switch self { + case .preMeal: return "preMeal" + case .custom: return "custom" + case .preset: return "preset" + case .activity: return "activity" + } + } +} + +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 cgmFetchCount = 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, + "cgmFetchCount": cgmFetchCount + ] + 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" - func presetDeactivated(context: TemporaryScheduleOverride.Context) {} + 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) + } + } } extension AutomaticDosingStrategy { diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index a4bb304c25..d775a298af 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -483,7 +483,21 @@ class LoopAppManager: NSObject { analyticsServicesManager.identifyWorkspaceGitRevision(workspaceGitRevision) } - analyticsServicesManager.identify("Dosing Strategy", value: settingsManager.loopSettings.automaticDosingStrategy.analyticsValue) + analyticsServicesManager.stateProvider = { [settingsManager = settingsManager!] in + AnalyticsState( + closedLoop: settingsManager.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) + } + if let cgmManager = deviceDataManager.cgmManager { + analyticsServicesManager.identifyCGMType(cgmManager.pluginIdentifier) + } let serviceNames = servicesManager.activeServices.map { $0.pluginIdentifier } analyticsServicesManager.identify("Services", array: serviceNames) diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index c7252128fa..b557f1840a 100644 --- a/Loop/Managers/ServicesManager.swift +++ b/Loop/Managers/ServicesManager.swift @@ -329,7 +329,7 @@ extension ServicesManager: ServiceDelegate { try await servicesManagerDelegate?.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) await remoteDataServicesManager.performUpload(for: .carb) - analyticsServicesManager.didAddCarbs(source: "Remote", amount: amountInGrams) + analyticsServicesManager.didAddCarbs(source: "Remote") } catch { NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: amountInGrams) throw error @@ -354,7 +354,7 @@ extension ServicesManager: ServiceDelegate { try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits, decisionId: decisionId) NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) await remoteDataServicesManager.performUpload(for: .dose) - analyticsServicesManager.didBolus(source: "Remote", units: amountInUnits) + analyticsServicesManager.didBolus(source: "Remote") } catch { NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) throw error diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index 3ccaac5c4a..466581b765 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -235,6 +235,8 @@ class SettingsManager { analyticsServicesManager?.didChangeInsulinModel() } + analyticsServicesManager?.didChangeLoopSettings(from: oldValue, to: newValue) + if newValue.dosingEnabled != oldValue.dosingEnabled { self.dosingEnabled = newValue.dosingEnabled } diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 182520acde..92a9557101 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -436,7 +436,7 @@ final class WatchDataManager: NSObject { if let carbEntry = bolus.carbEntry { let storedCarbEntry = try await loopDataManager.addCarbEntry(carbEntry) dosingDecision.carbEntry = storedCarbEntry - self.analyticsServicesManager?.didAddCarbs(source: "Watch", amount: storedCarbEntry.quantity.doubleValue(for: .gram)) + self.analyticsServicesManager?.didAddCarbs(source: "Watch") } else { dosingDecision.carbEntry = nil } @@ -449,7 +449,7 @@ final class WatchDataManager: NSObject { } try await deviceManager.enactBolus(units: bolus.value, decisionId: dosingDecision.id, activationType: bolus.activationType) - self.analyticsServicesManager?.didBolus(source: "Watch", units: bolus.value) + self.analyticsServicesManager?.didBolus(source: "Watch") } func handleWatchMessage(_ message: [String: Any]) async throws -> [String: Any] { diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 7b699618e7..b1289a714d 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1634,6 +1634,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } func presentSettings() { + analyticsServicesManager?.didDisplaySettingsScreen() let hostingController = DismissibleHostingController( rootView: SettingsView(viewModel: statusTableViewModel.settingsViewModel, localizedAppNameAndVersion: supportManager.localizedAppNameAndVersion) .environmentObject(deviceManager.displayGlucosePreference) diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 6a10939cad..b3b24dbc67 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -388,7 +388,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), isFavoriteFood: storedCarbEntry.favoriteFoodID != nil) + self.analyticsServicesManager?.didAddCarbs(source: "Phone", isFavoriteFood: storedCarbEntry.favoriteFoodID != nil) } else { self.presentAlert(.carbEntryPersistenceFailure) return false @@ -407,7 +407,7 @@ final class BolusEntryViewModel: ObservableObject { } catch { log.error("Failed to enact bolus: %{public}@", String(describing: error)) } - self.analyticsServicesManager?.didBolus(source: "Phone", units: amountToDeliver) + self.analyticsServicesManager?.didBolus(source: "Phone") } return true } From 35528a20c5540ae9c1dcc9b6eaa7e161c3921ee6 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 13 Oct 2026 10:17:33 -0500 Subject: [PATCH 2/3] Reset pump and CGM type on removal; record alert trigger - Set the Pump Type and CGM Type user properties to "None" when the device is removed, so later events aren't attributed to it. - Add trigger (immediate, delayed, repeating) and delayMinutes to "Alert Issued". Expiry alerts are issued days ahead as delayed alerts, which looked like alerts shown at pairing time. --- Loop/Managers/Alerts/AlertManager.swift | 2 +- Loop/Managers/AnalyticsServicesManager.swift | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index e08c6d3a3d..0ae736b7d6 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -384,7 +384,7 @@ extension AlertManager: AlertIssuer { deferredAlerts.append(alert) return } - analyticsServicesManager.didIssueAlert(identifier: alert.identifier.value, interruptionLevel: alert.interruptionLevel) + analyticsServicesManager.didIssueAlert(identifier: alert.identifier.value, interruptionLevel: alert.interruptionLevel, trigger: alert.trigger) scheduleAlertWithSchedulers(alert) await alertStore.recordIssued(alert: alert) } diff --git a/Loop/Managers/AnalyticsServicesManager.swift b/Loop/Managers/AnalyticsServicesManager.swift index 368e667b90..7d0571384e 100644 --- a/Loop/Managers/AnalyticsServicesManager.swift +++ b/Loop/Managers/AnalyticsServicesManager.swift @@ -156,6 +156,7 @@ final class AnalyticsServicesManager { func pumpWasRemoved() { logEvent("Pump Removed") + identifyPumpType("None") } func pumpWasAdded(identifier: String) { @@ -164,6 +165,7 @@ final class AnalyticsServicesManager { func cgmWasRemoved() { logEvent("CGM Removed") + identifyCGMType("None") } func cgmWasAdded(identifier: String) { @@ -253,8 +255,21 @@ final class AnalyticsServicesManager { } } - func didIssueAlert(identifier: String, interruptionLevel: Alert.InterruptionLevel) { - logEvent("Alert Issued", withProperties: ["identifier": identifier, "interruptionLevel": interruptionLevel.rawValue]) + func didIssueAlert(identifier: String, interruptionLevel: Alert.InterruptionLevel, trigger: Alert.Trigger) { + var properties: [String: Any] = ["identifier": identifier, "interruptionLevel": interruptionLevel.rawValue] + + switch trigger { + case .immediate: + properties["trigger"] = "immediate" + case .delayed(let interval): + properties["trigger"] = "delayed" + properties["delayMinutes"] = Int(interval.minutes) + case .repeating(let repeatInterval): + properties["trigger"] = "repeating" + properties["delayMinutes"] = Int(repeatInterval.minutes) + } + + logEvent("Alert Issued", withProperties: properties) } func didEnactOverride(kind: String, duration: TemporaryScheduleOverride.Duration, activityType: String? = nil) { From 5d0048aa1ddc2570d63a44076a7a0062781faecd Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 13 Oct 2026 10:32:04 -0500 Subject: [PATCH 3/3] Report alerts when displayed instead of when scheduled "Alert Issued" fired when an alert was scheduled, so expiry alerts issued days ahead were counted at pairing time even if they were later replaced or retracted. Replace it with "Alert Displayed" {identifier, interruptionLevel, scheduled}: - immediate alerts are reported when issued - delayed and repeating alerts are reported after a loop once they have come due without being retracted; only the latest issue of each identifier counts, since re-issuing replaces the scheduled alert - loop-not-running notifications that iOS delivered while Loop was not running are reported when Loop infers their delivery --- Loop/Managers/Alerts/AlertManager.swift | 18 ++++++++++- Loop/Managers/Alerts/AlertStore.swift | 32 +++++++++++++++++++ Loop/Managers/AnalyticsServicesManager.swift | 23 +++++-------- .../Managers/Alerts/AlertStoreTests.swift | 19 +++++++++++ 4 files changed, 76 insertions(+), 16 deletions(-) diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index 0ae736b7d6..b31985d9a1 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -184,6 +184,19 @@ public final class AlertManager { func loopDidComplete(_ lastLoopDate: Date? = nil) async { // use now if there is no lastLoopDate await rescheduleLoopNotRunningNotifications(lastLoopDate ?? Date()) + await reportDisplayedScheduledAlerts() + } + + private func reportDisplayedScheduledAlerts() async { + let now = getCurrentDate() + let start = UserDefaults.standard.scheduledAlertsReportedThrough ?? now + UserDefaults.standard.scheduledAlertsReportedThrough = now + guard start < now, let alerts = try? await alertStore.lookupScheduledAlertsDue(after: start, through: now) else { + return + } + for alert in alerts { + analyticsServicesManager.didDisplayAlert(identifier: alert.identifier.value, interruptionLevel: alert.interruptionLevel, scheduled: true) + } } private func rescheduleLoopNotRunningNotifications() { @@ -271,6 +284,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) await recordIssued(alert: alert, at: notification.alertAt) + analyticsServicesManager.didDisplayAlert(identifier: alertIdentifier.value, interruptionLevel: interruptionLevel, scheduled: true) } else { stillPendingNotifications.append(notification) } @@ -384,7 +398,9 @@ extension AlertManager: AlertIssuer { deferredAlerts.append(alert) return } - analyticsServicesManager.didIssueAlert(identifier: alert.identifier.value, interruptionLevel: alert.interruptionLevel, trigger: alert.trigger) + if alert.trigger == .immediate { + analyticsServicesManager.didDisplayAlert(identifier: alert.identifier.value, interruptionLevel: alert.interruptionLevel, scheduled: false) + } scheduleAlertWithSchedulers(alert) await alertStore.recordIssued(alert: alert) } diff --git a/Loop/Managers/Alerts/AlertStore.swift b/Loop/Managers/Alerts/AlertStore.swift index 1e5cd8fa51..11d4495c0d 100644 --- a/Loop/Managers/Alerts/AlertStore.swift +++ b/Loop/Managers/Alerts/AlertStore.swift @@ -197,6 +197,38 @@ 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) async throws -> [(identifier: Alert.Identifier, interruptionLevel: Alert.InterruptionLevel)] { + try await managedObjectContext.perform { + 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 + } + + return 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) + } + } + } + public func lookupAllAcknowledgedUnretractedRepeatingAlerts() async throws -> [StoredAlert] { try await managedObjectContext.perform { let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() diff --git a/Loop/Managers/AnalyticsServicesManager.swift b/Loop/Managers/AnalyticsServicesManager.swift index 7d0571384e..3a68ee2e92 100644 --- a/Loop/Managers/AnalyticsServicesManager.swift +++ b/Loop/Managers/AnalyticsServicesManager.swift @@ -255,21 +255,8 @@ final class AnalyticsServicesManager { } } - func didIssueAlert(identifier: String, interruptionLevel: Alert.InterruptionLevel, trigger: Alert.Trigger) { - var properties: [String: Any] = ["identifier": identifier, "interruptionLevel": interruptionLevel.rawValue] - - switch trigger { - case .immediate: - properties["trigger"] = "immediate" - case .delayed(let interval): - properties["trigger"] = "delayed" - properties["delayMinutes"] = Int(interval.minutes) - case .repeating(let repeatInterval): - properties["trigger"] = "repeating" - properties["delayMinutes"] = Int(repeatInterval.minutes) - } - - logEvent("Alert Issued", withProperties: properties) + func didDisplayAlert(identifier: String, interruptionLevel: Alert.InterruptionLevel, scheduled: Bool) { + logEvent("Alert Displayed", withProperties: ["identifier": identifier, "interruptionLevel": interruptionLevel.rawValue, "scheduled": scheduled], outOfSession: scheduled) } func didEnactOverride(kind: String, duration: TemporaryScheduleOverride.Duration, activityType: String? = nil) { @@ -360,6 +347,12 @@ struct AnalyticsDailySummary: Codable, Equatable { 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 { diff --git a/LoopTests/Managers/Alerts/AlertStoreTests.swift b/LoopTests/Managers/Alerts/AlertStoreTests.swift index 5f56baf124..8378bb5925 100644 --- a/LoopTests/Managers/Alerts/AlertStoreTests.swift +++ b/LoopTests/Managers/Alerts/AlertStoreTests.swift @@ -91,6 +91,25 @@ class AlertStoreTests: XCTestCase { XCTAssertEqual(999, newAnchor?.modificationCounter) } + func testLookupScheduledAlertsDue() async throws { + let issued = Self.historicDate + let due = issued.addingTimeInterval(Self.delayedAlertDelay) + await alertStore.recordIssued(alert: alert1, at: issued) + await alertStore.recordIssued(alert: delayedAlert, at: issued) + await alertStore.recordIssued(alert: repeatingAlert, at: issued) + try await alertStore.recordRetraction(of: Self.repeatingAlertIdentifier, at: due.addingTimeInterval(-1)) + + var dueAlerts = try await alertStore.lookupScheduledAlertsDue(after: issued, through: due.addingTimeInterval(1)) + XCTAssertEqual([Self.delayedAlertIdentifier], dueAlerts.map { $0.identifier }) + + dueAlerts = try await alertStore.lookupScheduledAlertsDue(after: issued, through: due.addingTimeInterval(-1)) + XCTAssertTrue(dueAlerts.isEmpty) + + await alertStore.recordIssued(alert: delayedAlert, at: issued.addingTimeInterval(10)) + dueAlerts = try await alertStore.lookupScheduledAlertsDue(after: issued, through: due.addingTimeInterval(1)) + XCTAssertTrue(dueAlerts.isEmpty) + } + func testRecordIssued() async throws { await alertStore.recordIssued(alert: alert1, at: Self.historicDate) let storedAlerts = try await self.alertStore.fetch(identifier: Self.identifier1)