From 56a4b02aa2b8d85c7953f30818f325dfc0ddb62e Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 26 Oct 2026 22:35:12 -0500 Subject: [PATCH 1/6] Show how long the current setup has been unchanged, and its last change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings gets a Current Setup row under Statistics, e.g. "5 days · Last change: Carb Ratios". It opens a screen with the time since the last change, its date, what changed, and a note that about 2 weeks with the same setup is a good amount of time to see a change in outcomes. The last change is found in the stored settings history by comparing consecutive records: basal rates, insulin sensitivities, carb ratios, correction range, pre-meal range, glucose safety limit, delivery limits, insulin type and model, and dosing strategy. Schedules are compared by their values, not their time zone. Changes within an hour of the latest are reported together. Presets, closed loop, alerts, devices and the glucose unit don't count. With no change in the history, the setup is unchanged since at least the oldest record. Turning an algorithm experiment on or off records the time and which one. An experiment that was already on before this build counts from the first launch that sees it, as a lower bound. --- Loop.xcodeproj/project.pbxproj | 12 ++ Loop/Managers/LoopAppManager.swift | 5 + Loop/Models/SetupChange.swift | 133 ++++++++++++++++++ Loop/View Models/SettingsViewModel.swift | 2 + Loop/Views/CurrentSetupView.swift | 92 ++++++++++++ ...ingsView+algorithmExperimentsSection.swift | 41 ++++++ Loop/Views/SettingsView.swift | 30 ++++ LoopTests/Models/SetupChangeTests.swift | 97 +++++++++++++ 8 files changed, 412 insertions(+) create mode 100644 Loop/Models/SetupChange.swift create mode 100644 Loop/Views/CurrentSetupView.swift create mode 100644 LoopTests/Models/SetupChangeTests.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 0ef947c208..f389fd081e 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -534,6 +534,9 @@ C16DA84222E8E112008624C2 /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16DA84122E8E112008624C2 /* PluginManager.swift */; }; C16F51192B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */; }; 52ECDA0F09B4C8F23A4B450E /* InsulinDeliveryHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C16D18C8806FF48331134C /* InsulinDeliveryHistory.swift */; }; + 8879F70ABCDF1E10619547A4 /* SetupChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12CD655083D4816F87B13463 /* SetupChange.swift */; }; + 6D16FF26E7F84F97B5DB6A88 /* CurrentSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFD4A26A23202AF2EA506FCF /* CurrentSetupView.swift */; }; + D70F014FFB67BA58678452E9 /* SetupChangeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC166B8AD05800AFCD474E /* SetupChangeTests.swift */; }; C16F511B2B89363A00EFD7A1 /* SimpleInsulinDose.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */; }; C16FC0B02A99392F0025E239 /* live_capture_input.json in Resources */ = {isa = PBXBuildFile; fileRef = C16FC0AF2A99392F0025E239 /* live_capture_input.json */; }; C1735B1E2A0809830082BB8A /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = C1735B1D2A0809830082BB8A /* ZIPFoundation */; }; @@ -1374,6 +1377,9 @@ C16DA84122E8E112008624C2 /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = ""; }; C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDataAlgorithmInput.swift; sourceTree = ""; }; C4C16D18C8806FF48331134C /* InsulinDeliveryHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryHistory.swift; sourceTree = ""; }; + 12CD655083D4816F87B13463 /* SetupChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupChange.swift; sourceTree = ""; }; + CFD4A26A23202AF2EA506FCF /* CurrentSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentSetupView.swift; sourceTree = ""; }; + 03EC166B8AD05800AFCD474E /* SetupChangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupChangeTests.swift; sourceTree = ""; }; C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleInsulinDose.swift; sourceTree = ""; }; C16FC0AF2A99392F0025E239 /* live_capture_input.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = live_capture_input.json; sourceTree = ""; }; C1742331259BEADC00399C9D /* ManualEntryDoseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntryDoseView.swift; sourceTree = ""; }; @@ -1819,6 +1825,7 @@ A987CD4824A58A0100439ADC /* ZipArchive.swift */, C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */, C4C16D18C8806FF48331134C /* InsulinDeliveryHistory.swift */, + 12CD655083D4816F87B13463 /* SetupChange.swift */, C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */, C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */, 84213C882D94941000642E78 /* InsulinDeliveryLogEvent.swift */, @@ -2146,6 +2153,7 @@ B43B5C532EAFBF110096A6AE /* RecentGlucoseTableViewCell.xib */, C151634D2FA2C9C800FEECE8 /* RequiredVersionUpdateView.swift */, 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */, + CFD4A26A23202AF2EA506FCF /* CurrentSetupView.swift */, FEEDF0000000000000000004 /* Statistics/TimeInRangeBar.swift */, 747DA22B61531CCAAC79E86D /* Statistics/DailyInsulinChartView.swift */, FEEDF0000000000000000003 /* Statistics/AGPChartView.swift */, @@ -2757,6 +2765,7 @@ children = ( C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */, F69CE2EE3714858772FE7A26 /* InsulinDeliveryHistoryTests.swift */, + 03EC166B8AD05800AFCD474E /* SetupChangeTests.swift */, FEEDF0000000000000000005 /* GlucoseStatisticsTests.swift */, A9DFAFB224F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift */, A963B279252CEBAE0062AA12 /* SetBolusUserInfoTests.swift */, @@ -3757,6 +3766,7 @@ 14C970842C5C2FB400E8A01B /* HowCarbEffectsWorksView.swift in Sources */, 1D6B1B6726866D89009AC446 /* AlertPermissionsChecker.swift in Sources */, 52ECDA0F09B4C8F23A4B450E /* InsulinDeliveryHistory.swift in Sources */, + 8879F70ABCDF1E10619547A4 /* SetupChange.swift in Sources */, C16F511B2B89363A00EFD7A1 /* SimpleInsulinDose.swift in Sources */, 4F08DE8F1E7BB871006741EA /* CollectionType+Loop.swift in Sources */, A9F703772489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift in Sources */, @@ -3772,6 +3782,7 @@ 1D12D3B92548EFDD00B53E8B /* main.swift in Sources */, A9DCF32A25B0FABF00C89088 /* LoopUIColorPalette+Default.swift in Sources */, 1DDE273E24AEA4B000796622 /* SettingsView.swift in Sources */, + 6D16FF26E7F84F97B5DB6A88 /* CurrentSetupView.swift in Sources */, FEEDB0000000000000000004 /* Statistics/TimeInRangeBar.swift in Sources */, A3C9F155FD92DC188BD9A21F /* Statistics/DailyInsulinChartView.swift in Sources */, FEEDB0000000000000000003 /* Statistics/AGPChartView.swift in Sources */, @@ -4027,6 +4038,7 @@ A9A63F8E246B271600588D5B /* NSTimeInterval.swift in Sources */, C152B9F72C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift in Sources */, 30E4A68B8C914C5E4B245555 /* InsulinDeliveryHistoryTests.swift in Sources */, + D70F014FFB67BA58678452E9 /* SetupChangeTests.swift in Sources */, FEEDB0000000000000000005 /* GlucoseStatisticsTests.swift in Sources */, A9DFAFB324F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift in Sources */, A963B27A252CEBAE0062AA12 /* SetBolusUserInfoTests.swift in Sources */, diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index fb162f500c..e144da6f12 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -495,6 +495,7 @@ class LoopAppManager: NSObject { ) } analyticsServicesManager.identifyState() + UserDefaults.standard.updateAlgorithmExperimentsChangeDate() if let pumpManager = deviceDataManager.pumpManager { analyticsServicesManager.identifyPumpType(pumpManager.pluginIdentifier) analyticsServicesManager.identifyPumpModel(pumpManager.status.device.model) @@ -609,6 +610,10 @@ class LoopAppManager: NSObject { guard let loopDataManager else { throw CancellationError() } return try await loopDataManager.insulinDeliveryHistory(days: days) } + viewModel.setupChange = { [weak settingsManager] in + let history = try await settingsManager?.settingsStore?.getStoredSettings() ?? [] + return SetupChange.latest(SetupChange.latest(in: history), UserDefaults.standard.algorithmExperimentsChange) + } return viewModel }() diff --git a/Loop/Models/SetupChange.swift b/Loop/Models/SetupChange.swift new file mode 100644 index 0000000000..75ffa66156 --- /dev/null +++ b/Loop/Models/SetupChange.swift @@ -0,0 +1,133 @@ +// +// SetupChange.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopAlgorithm + +/// The most recent change to the settings that shape dosing, used to tell how long the current setup has run. +struct SetupChange: Equatable { + enum Item: Equatable { + case therapySetting(TherapySetting) + case insulinType + case insulinModel + case dosingStrategy + case experiment(ExperimentOption, enabled: Bool) + } + + var date: Date + /// The setup is known to be unchanged since `date`, but may be older. + var isLowerBound: Bool + var items: [Item] + + /// Changes this close to the latest one are reported together, as one edit. + static let groupingInterval = TimeInterval(hours: 1) + + /// Finds the latest change in a settings history, in any order. Without one, the setup is + /// unchanged since at least the oldest record. + static func latest(in history: [StoredSettings]) -> SetupChange? { + let sorted = history.sorted { $0.date < $1.date } + guard let first = sorted.first else { + return nil + } + let changes = zip(sorted, sorted.dropFirst()).compactMap { previous, current -> SetupChange? in + let items = differences(from: previous, to: current) + return items.isEmpty ? nil : SetupChange(date: current.date, isLowerBound: false, items: items) + } + guard let last = changes.last else { + return SetupChange(date: first.date, isLowerBound: true, items: []) + } + return changes.reversed().reduce(SetupChange(date: last.date, isLowerBound: false, items: [])) { result, change in + change.date >= last.date - groupingInterval ? result.merging(change) : result + } + } + + /// The later of two changes, with both reported when they're close together. + static func latest(_ a: SetupChange?, _ b: SetupChange?) -> SetupChange? { + guard let a else { return b } + guard let b else { return a } + let (earlier, later) = a.date <= b.date ? (a, b) : (b, a) + guard !later.isLowerBound, !earlier.isLowerBound, earlier.date >= later.date - groupingInterval else { + return later + } + return later.merging(earlier) + } + + private func merging(_ other: SetupChange) -> SetupChange { + var merged = self + merged.date = max(date, other.date) + for item in other.items where !merged.items.contains(item) { + merged.items.append(item) + } + return merged + } + + static func differences(from a: StoredSettings, to b: StoredSettings) -> [Item] { + var items: [Item] = [] + if a.basalRateSchedule?.items != b.basalRateSchedule?.items { + items.append(.therapySetting(.basalRate)) + } + if !sameSchedule(a.insulinSensitivitySchedule, b.insulinSensitivitySchedule) { + items.append(.therapySetting(.insulinSensitivity)) + } + if !sameSchedule(a.carbRatioSchedule, b.carbRatioSchedule) { + items.append(.therapySetting(.carbRatio)) + } + if !sameRanges(a.glucoseTargetRangeSchedule?.quantityRanges.map(\.value), b.glucoseTargetRangeSchedule?.quantityRanges.map(\.value)) + || a.glucoseTargetRangeSchedule?.quantityRanges.map(\.startTime) != b.glucoseTargetRangeSchedule?.quantityRanges.map(\.startTime) { + items.append(.therapySetting(.glucoseTargetRange)) + } + if !sameRanges(a.preMealTargetRange.map { [$0] }, b.preMealTargetRange.map { [$0] }) { + items.append(.therapySetting(.preMealCorrectionRangeOverride)) + } + if !sameQuantity(a.suspendThreshold?.quantity, b.suspendThreshold?.quantity) { + items.append(.therapySetting(.suspendThreshold)) + } + if a.maximumBasalRatePerHour != b.maximumBasalRatePerHour || a.maximumBolus != b.maximumBolus { + items.append(.therapySetting(.deliveryLimits)) + } + if a.insulinType != b.insulinType { + items.append(.insulinType) + } + if a.defaultRapidActingModel != b.defaultRapidActingModel { + items.append(.insulinModel) + } + if a.automaticDosingStrategy != b.automaticDosingStrategy { + items.append(.dosingStrategy) + } + return items + } + + private static func sameSchedule(_ a: SingleQuantitySchedule?, _ b: SingleQuantitySchedule?) -> Bool { + guard let a, let b else { + return a == nil && b == nil + } + let aItems = a.quantities, bItems = b.quantities + return aItems.count == bItems.count && zip(aItems, bItems).allSatisfy { x, y in + x.startTime == y.startTime && sameQuantity(x.value, y.value) + } + } + + private static func sameRanges(_ a: [ClosedRange]?, _ b: [ClosedRange]?) -> Bool { + guard let a, let b else { + return a == nil && b == nil + } + return a.count == b.count && zip(a, b).allSatisfy { x, y in + sameQuantity(x.lowerBound, y.lowerBound) && sameQuantity(x.upperBound, y.upperBound) + } + } + + // A value saved in mmol/L is rounded to 0.1, which can be ~2% from the same value in mg/dL + private static func sameQuantity(_ a: LoopQuantity?, _ b: LoopQuantity?) -> Bool { + guard let a, let b else { + return a == nil && b == nil + } + let x = a.doubleValue(for: a.unit), y = b.doubleValue(for: a.unit) + let tolerance = a.unit == b.unit ? 1e-9 : 0.02 + return abs(x - y) <= tolerance * max(abs(x), abs(y)) + } +} diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 78731a9c8c..1aef55c5c9 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -121,6 +121,8 @@ class SettingsViewModel { @ObservationIgnored var insulinDeliveryHistory: ((Int) async throws -> InsulinDeliveryHistory)? + @ObservationIgnored var setupChange: (() async throws -> SetupChange?)? + @MainActor var showDeleteTestData: Bool { availableSupports.contains(where: { $0.showsDeleteTestDataUI }) diff --git a/Loop/Views/CurrentSetupView.swift b/Loop/Views/CurrentSetupView.swift new file mode 100644 index 0000000000..08fd7742c9 --- /dev/null +++ b/Loop/Views/CurrentSetupView.swift @@ -0,0 +1,92 @@ +// +// CurrentSetupView.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI +import LoopAlgorithm + +struct CurrentSetupView: View { + let setupChange: SetupChange? + + var body: some View { + List { + Section { + if let setupChange { + LabeledContent(NSLocalizedString("Unchanged For", comment: "Title of the row showing how long the current setup has run"), value: setupChange.durationDescription) + LabeledContent( + setupChange.isLowerBound + ? NSLocalizedString("No Change Recorded Since", comment: "Title of the row showing the oldest date the setup is known to be unchanged since") + : NSLocalizedString("Last Change", comment: "Title of the row showing when the setup last changed"), + value: setupChange.date.formatted(date: .abbreviated, time: .shortened)) + } else { + Text(NSLocalizedString("No settings have been recorded yet.", comment: "Shown on the current setup screen when there is no settings history")) + .foregroundColor(.secondary) + } + } + if let setupChange, !setupChange.items.isEmpty { + Section(header: SectionHeader(label: NSLocalizedString("What Changed", comment: "Header of the list of settings changed in the last setup change"))) { + ForEach(setupChange.items.indices, id: \.self) { index in + Text(setupChange.items[index].title) + } + } + } + Section(footer: Text(NSLocalizedString("Changes to therapy settings, insulin type or model, dosing strategy and algorithm experiments start a new setup. Running a preset doesn't.\n\nAbout 2 weeks with the same setup is a good amount of time to see a change in outcomes such as time in range and lows. Over just a few days, ordinary day-to-day variation can hide or exaggerate the effect.", comment: "Footer explaining what starts a new setup and how long to keep one"))) { + EmptyView() + } + } + .insetGroupedListStyle() + .navigationTitle(NSLocalizedString("Current Setup", comment: "Title of the current setup screen")) + } +} + +extension SetupChange { + var durationDescription: String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.day, .hour, .minute] + formatter.maximumUnitCount = 1 + formatter.unitsStyle = .full + let duration = formatter.string(from: max(60, Date().timeIntervalSince(date))) ?? "" + if isLowerBound { + return String(format: NSLocalizedString("at least %@", comment: "Time since the setup last changed, when the change date is not known exactly (1: duration, e.g. 3 days)"), duration) + } + return duration + } +} + +extension SetupChange.Item { + var title: String { + switch self { + case .therapySetting(let setting): + return setting.title + case .insulinType: + return NSLocalizedString("Insulin Type", comment: "Setup change item for the insulin type") + case .insulinModel: + return NSLocalizedString("Insulin Model", comment: "Setup change item for the insulin model") + case .dosingStrategy: + return NSLocalizedString("Dosing Strategy", comment: "Setup change item for the dosing strategy") + case .experiment(let experiment, let enabled): + let format = enabled + ? NSLocalizedString("%@ turned on", comment: "Setup change item for an algorithm experiment turned on (1: experiment name)") + : NSLocalizedString("%@ turned off", comment: "Setup change item for an algorithm experiment turned off (1: experiment name)") + return String(format: format, experiment.title) + } + } +} + +extension ExperimentOption { + var title: String { + switch self { + case .guardAndRunway: + return NSLocalizedString("Guard + Runway", comment: "Title of guard and runway experiment") + case .glucoseBasedApplicationFactor: + return NSLocalizedString("Glucose Based Partial Application", comment: "Title of glucose based partial application experiment") + case .integralRetrospectiveCorrection: + return NSLocalizedString("Integral Retrospective Correction", comment: "Title of integral retrospective correction experiment") + } + } +} diff --git a/Loop/Views/SettingsView+algorithmExperimentsSection.swift b/Loop/Views/SettingsView+algorithmExperimentsSection.swift index 5ff430b87e..33e7bb84d3 100644 --- a/Loop/Views/SettingsView+algorithmExperimentsSection.swift +++ b/Loop/Views/SettingsView+algorithmExperimentsSection.swift @@ -10,6 +10,7 @@ import Foundation import SwiftUI import LoopKit import LoopKitUI +import LoopAlgorithm extension SettingsView { internal var algorithmExperimentsSection: some View { @@ -76,11 +77,18 @@ public struct ExperimentsSettingsView: View { name: NSLocalizedString("Integral Retrospective Correction", comment: "Title of integral retrospective correction experiment"), enabled: isIntegralRetrospectiveCorrectionEnabled) } + Text(NSLocalizedString("About 2 weeks with the same setup is a good amount of time to see a change in outcomes such as time in range and lows. Over just a few days, ordinary day-to-day variation can hide or exaggerate the effect.", comment: "Suggested length of time to try an algorithm experiment setup.")) + .font(.footnote) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) Spacer() } .padding() } .navigationBarTitleDisplayMode(.inline) + .onChange(of: isGuardAndRunwayEnabled) { UserDefaults.standard.recordAlgorithmExperimentChange(.guardAndRunway, enabled: isGuardAndRunwayEnabled) } + .onChange(of: isGlucoseBasedApplicationFactorEnabled) { UserDefaults.standard.recordAlgorithmExperimentChange(.glucoseBasedApplicationFactor, enabled: isGlucoseBasedApplicationFactorEnabled) } + .onChange(of: isIntegralRetrospectiveCorrectionEnabled) { UserDefaults.standard.recordAlgorithmExperimentChange(.integralRetrospectiveCorrection, enabled: isIntegralRetrospectiveCorrectionEnabled) } } } @@ -90,6 +98,39 @@ extension UserDefaults { case GlucoseBasedApplicationFactorEnabled = "com.loopkit.algorithmExperiments.glucoseBasedApplicationFactorEnabled" case IntegralRetrospectiveCorrectionEnabled = "com.loopkit.algorithmExperiments.integralRetrospectiveCorrectionEnabled" case GuardAndRunwayEnabled = "com.loopkit.algorithmExperiments.guardAndRunwayEnabled" + case ChangeDate = "com.loopkit.algorithmExperiments.changeDate" + case ChangeDateIsLowerBound = "com.loopkit.algorithmExperiments.changeDateIsLowerBound" + case ChangedExperiment = "com.loopkit.algorithmExperiments.changedExperiment" + case ChangedExperimentEnabled = "com.loopkit.algorithmExperiments.changedExperimentEnabled" + } + + var algorithmExperimentsChange: SetupChange? { + guard let date = object(forKey: Key.ChangeDate.rawValue) as? Date else { + return nil + } + var items: [SetupChange.Item] = [] + if let experiment = string(forKey: Key.ChangedExperiment.rawValue).flatMap(ExperimentOption.init(rawValue:)) { + items.append(.experiment(experiment, enabled: bool(forKey: Key.ChangedExperimentEnabled.rawValue))) + } + return SetupChange(date: date, isLowerBound: bool(forKey: Key.ChangeDateIsLowerBound.rawValue), items: items) + } + + fileprivate func recordAlgorithmExperimentChange(_ experiment: ExperimentOption, enabled: Bool) { + set(Date(), forKey: Key.ChangeDate.rawValue) + removeObject(forKey: Key.ChangeDateIsLowerBound.rawValue) + set(experiment.rawValue, forKey: Key.ChangedExperiment.rawValue) + set(enabled, forKey: Key.ChangedExperimentEnabled.rawValue) + } + + // An experiment that was on before changes were recorded counts from the first launch that sees it + func updateAlgorithmExperimentsChangeDate() { + guard object(forKey: Key.ChangeDate.rawValue) == nil, + guardAndRunwayEnabled || glucoseBasedApplicationFactorEnabled || integralRetrospectiveCorrectionEnabled + else { + return + } + set(Date(), forKey: Key.ChangeDate.rawValue) + set(true, forKey: Key.ChangeDateIsLowerBound.rawValue) } var glucoseBasedApplicationFactorEnabled: Bool { diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 00755b87d1..fb26cd5675 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -62,6 +62,7 @@ struct SettingsView: View { @State private var alert: Destination.Alert? @State private var sheet: Destination.Sheet? @State private var pluginSelection: (() -> Void)? + @State private var setupChange: SetupChange? var localizedAppNameAndVersion: String @@ -357,10 +358,39 @@ extension SettingsView { ) .accessibilityIdentifier("settingsViewStatistics") } + NavigationLink(destination: CurrentSetupView(setupChange: setupChange)) { + LargeButton( + action: {}, + includeArrow: false, + imageView: Image(systemName: "clock.arrow.circlepath") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30), + label: NSLocalizedString("Current Setup", comment: "Current setup settings button text"), + descriptiveText: currentSetupDescriptiveText + ) + .accessibilityIdentifier("settingsViewCurrentSetup") + } + .task { + setupChange = try? await viewModel.setupChange?() + } } } } + private var currentSetupDescriptiveText: String { + guard let setupChange else { + return NSLocalizedString("How long your settings have been unchanged", comment: "Current setup settings descriptive text before the setup is known") + } + guard let item = setupChange.items.first else { + return setupChange.durationDescription + } + let changed = setupChange.items.count > 1 + ? String(format: NSLocalizedString("%@ and more", comment: "Several settings changed together (1: the first one)"), item.title) + : item.title + return String(format: NSLocalizedString("%1$@ · Last change: %2$@", comment: "Current setup settings descriptive text (1: time since the last change, 2: what changed)"), setupChange.durationDescription, changed) + } + private func healthKitSharingStatus(for type: HKObjectType) -> HKAuthorizationStatus { viewModel.deviceManager?.healthKitSharingStatus(for: type) ?? .notDetermined } diff --git a/LoopTests/Models/SetupChangeTests.swift b/LoopTests/Models/SetupChangeTests.swift new file mode 100644 index 0000000000..9b8ff42c26 --- /dev/null +++ b/LoopTests/Models/SetupChangeTests.swift @@ -0,0 +1,97 @@ +// +// SetupChangeTests.swift +// LoopTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +import LoopKit +import LoopAlgorithm +@testable import Loop + +final class SetupChangeTests: XCTestCase { + private let start = Date(timeIntervalSinceReferenceDate: 800_000_000) + + private func settings( + at hours: Double, + basal: Double = 1.0, + isf: Double = 50, + isfUnit: LoopUnit = .milligramsPerDeciliter, + maximumBolus: Double = 10, + strategy: AutomaticDosingStrategy = .automaticBolus, + dosingEnabled: Bool = true, + deviceToken: String? = nil, + timeZone: TimeZone = TimeZone(identifier: "America/Chicago")! + ) -> StoredSettings { + StoredSettings( + date: start.addingTimeInterval(.hours(hours)), + dosingEnabled: dosingEnabled, + maximumBolus: maximumBolus, + deviceToken: deviceToken, + basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: basal)], timeZone: timeZone), + insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: isfUnit, dailyItems: [RepeatingScheduleValue(startTime: 0, value: isf)], timeZone: timeZone), + automaticDosingStrategy: strategy) + } + + func testNoChangeIsLowerBoundAtOldestRecord() { + let change = SetupChange.latest(in: [settings(at: 5), settings(at: 0), settings(at: 10)]) + XCTAssertEqual(change, SetupChange(date: start, isLowerBound: true, items: [])) + } + + func testEmptyHistory() { + XCTAssertNil(SetupChange.latest(in: [])) + } + + func testLatestChangeWins() { + let change = SetupChange.latest(in: [settings(at: 0), settings(at: 10, basal: 1.2), settings(at: 20, basal: 1.2, maximumBolus: 8)]) + XCTAssertEqual(change, SetupChange(date: start.addingTimeInterval(.hours(20)), isLowerBound: false, items: [.therapySetting(.deliveryLimits)])) + } + + func testChangesWithinAnHourAreGrouped() { + let change = SetupChange.latest(in: [settings(at: 0), settings(at: 10, isf: 45), settings(at: 10.5, isf: 45, strategy: .tempBasalOnly)]) + XCTAssertEqual(change?.date, start.addingTimeInterval(.hours(10.5))) + XCTAssertEqual(change?.items, [.dosingStrategy, .therapySetting(.insulinSensitivity)]) + } + + func testIgnoredChanges() { + let other = TimeZone(identifier: "Europe/Paris")! + let change = SetupChange.latest(in: [ + settings(at: 0), + settings(at: 1, dosingEnabled: false), + settings(at: 2, deviceToken: "abc"), + settings(at: 3, timeZone: other), + ]) + XCTAssertEqual(change, SetupChange(date: start, isLowerBound: true, items: [])) + } + + func testSameSensitivityInAnotherUnitIsNotAChange() { + let mmol = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 50).doubleValue(for: .millimolesPerLiter) + let change = SetupChange.latest(in: [settings(at: 0), settings(at: 1, isf: (mmol * 10).rounded() / 10, isfUnit: .millimolesPerLiter)]) + XCTAssertEqual(change?.isLowerBound, true) + } + + func testExperimentChangeAfterSettingsChange() { + let settingsChange = SetupChange(date: start, isLowerBound: false, items: [.therapySetting(.carbRatio)]) + let experimentChange = SetupChange(date: start.addingTimeInterval(.hours(30)), isLowerBound: false, items: [.experiment(.guardAndRunway, enabled: true)]) + XCTAssertEqual(SetupChange.latest(settingsChange, experimentChange), experimentChange) + } + + func testCloseChangesAreMerged() { + let settingsChange = SetupChange(date: start, isLowerBound: false, items: [.therapySetting(.carbRatio)]) + let experimentChange = SetupChange(date: start.addingTimeInterval(.minutes(20)), isLowerBound: false, items: [.experiment(.guardAndRunway, enabled: true)]) + XCTAssertEqual(SetupChange.latest(settingsChange, experimentChange)?.items, [.experiment(.guardAndRunway, enabled: true), .therapySetting(.carbRatio)]) + } + + func testLaterLowerBoundWins() { + let settingsChange = SetupChange(date: start, isLowerBound: false, items: [.therapySetting(.carbRatio)]) + let experimentsBound = SetupChange(date: start.addingTimeInterval(.days(3)), isLowerBound: true, items: []) + XCTAssertEqual(SetupChange.latest(settingsChange, experimentsBound), experimentsBound) + } + + func testChangeAfterLowerBoundWins() { + let settingsBound = SetupChange(date: start, isLowerBound: true, items: []) + let experimentChange = SetupChange(date: start.addingTimeInterval(.minutes(20)), isLowerBound: false, items: [.experiment(.integralRetrospectiveCorrection, enabled: false)]) + XCTAssertEqual(SetupChange.latest(settingsBound, experimentChange), experimentChange) + } +} From c0a7651a24d011f2ac2c30231891f10f699ec17b Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 26 Oct 2026 23:43:07 -0500 Subject: [PATCH 2/6] Don't count the pump's missing insulin type as a setup change The stored insulin type comes from the pump's status, which has none while the pump is being replaced or hasn't loaded yet, so the history flips between a type and none. Carry the last known type across those gaps and only count a change from one type to another. --- Loop/Models/SetupChange.swift | 11 +++++++++-- LoopTests/Models/SetupChangeTests.swift | 10 ++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/Loop/Models/SetupChange.swift b/Loop/Models/SetupChange.swift index 75ffa66156..bf616faf2e 100644 --- a/Loop/Models/SetupChange.swift +++ b/Loop/Models/SetupChange.swift @@ -30,7 +30,14 @@ struct SetupChange: Equatable { /// Finds the latest change in a settings history, in any order. Without one, the setup is /// unchanged since at least the oldest record. static func latest(in history: [StoredSettings]) -> SetupChange? { - let sorted = history.sorted { $0.date < $1.date } + // The insulin type comes from the pump, which has none while it's being replaced + var insulinType: InsulinType? + let sorted = history.sorted { $0.date < $1.date }.map { settings in + var settings = settings + settings.insulinType = settings.insulinType ?? insulinType + insulinType = settings.insulinType + return settings + } guard let first = sorted.first else { return nil } @@ -90,7 +97,7 @@ struct SetupChange: Equatable { if a.maximumBasalRatePerHour != b.maximumBasalRatePerHour || a.maximumBolus != b.maximumBolus { items.append(.therapySetting(.deliveryLimits)) } - if a.insulinType != b.insulinType { + if let aType = a.insulinType, let bType = b.insulinType, aType != bType { items.append(.insulinType) } if a.defaultRapidActingModel != b.defaultRapidActingModel { diff --git a/LoopTests/Models/SetupChangeTests.swift b/LoopTests/Models/SetupChangeTests.swift index 9b8ff42c26..1cd0b15de0 100644 --- a/LoopTests/Models/SetupChangeTests.swift +++ b/LoopTests/Models/SetupChangeTests.swift @@ -22,6 +22,7 @@ final class SetupChangeTests: XCTestCase { strategy: AutomaticDosingStrategy = .automaticBolus, dosingEnabled: Bool = true, deviceToken: String? = nil, + insulinType: InsulinType? = .novolog, timeZone: TimeZone = TimeZone(identifier: "America/Chicago")! ) -> StoredSettings { StoredSettings( @@ -29,6 +30,7 @@ final class SetupChangeTests: XCTestCase { dosingEnabled: dosingEnabled, maximumBolus: maximumBolus, deviceToken: deviceToken, + insulinType: insulinType, basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: basal)], timeZone: timeZone), insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: isfUnit, dailyItems: [RepeatingScheduleValue(startTime: 0, value: isf)], timeZone: timeZone), automaticDosingStrategy: strategy) @@ -61,10 +63,18 @@ final class SetupChangeTests: XCTestCase { settings(at: 1, dosingEnabled: false), settings(at: 2, deviceToken: "abc"), settings(at: 3, timeZone: other), + settings(at: 4, insulinType: nil), + settings(at: 5), ]) XCTAssertEqual(change, SetupChange(date: start, isLowerBound: true, items: [])) } + func testInsulinTypeChange() { + let change = SetupChange.latest(in: [settings(at: 0), settings(at: 1, insulinType: nil), settings(at: 2, insulinType: .fiasp)]) + XCTAssertEqual(change?.items, [.insulinType]) + XCTAssertEqual(change?.date, start.addingTimeInterval(.hours(2))) + } + func testSameSensitivityInAnotherUnitIsNotAChange() { let mmol = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 50).doubleValue(for: .millimolesPerLiter) let change = SetupChange.latest(in: [settings(at: 0), settings(at: 1, isf: (mmol * 10).rounded() / 10, isfUnit: .millimolesPerLiter)]) From a8671a67df275d422b635e397184b0fc6a41cb94 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 26 Oct 2026 23:50:48 -0500 Subject: [PATCH 3/6] Compare outcomes since the last setup change with the period before it The Current Setup screen now opens with a summary, e.g. "It's been 5 days since your last setup change: Carb Ratios. Compared with the 2 weeks before it, time in range rose from 68% to 79%, and time below 70 mg/dL stayed about the same at 5.6%." Below it are time-in-range bars for both periods, before and after values for time in range, time below 70, average glucose and insulin per day, and the median glucose by time of day before (dashed) and since (solid) the change. The before period is the 14 days before the change, cut short at the change before it so it covers one setup. It's left out with less than a day or less than 70% CGM data. Results start a day after the change, and while the setup has run under 2 weeks the summary says the numbers can still change a lot. Insulin per day is shown only where the dose records cover the period. --- Loop.xcodeproj/project.pbxproj | 16 +++ Loop/Models/SetupChange.swift | 21 ++- Loop/Models/SetupComparison.swift | 64 +++++++++ Loop/View Models/CurrentSetupViewModel.swift | 50 +++++++ Loop/Views/CurrentSetupView.swift | 127 +++++++++++++++++- Loop/Views/SettingsView.swift | 7 +- Loop/Views/Statistics/AGPChartView.swift | 2 +- .../Statistics/SetupComparisonCharts.swift | 121 +++++++++++++++++ LoopTests/Models/SetupChangeTests.swift | 9 +- LoopTests/Models/SetupComparisonTests.swift | 108 +++++++++++++++ 10 files changed, 514 insertions(+), 11 deletions(-) create mode 100644 Loop/Models/SetupComparison.swift create mode 100644 Loop/View Models/CurrentSetupViewModel.swift create mode 100644 Loop/Views/Statistics/SetupComparisonCharts.swift create mode 100644 LoopTests/Models/SetupComparisonTests.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index f389fd081e..aaaeaf19c3 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -535,6 +535,10 @@ C16F51192B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */; }; 52ECDA0F09B4C8F23A4B450E /* InsulinDeliveryHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C16D18C8806FF48331134C /* InsulinDeliveryHistory.swift */; }; 8879F70ABCDF1E10619547A4 /* SetupChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12CD655083D4816F87B13463 /* SetupChange.swift */; }; + 2622474F63F391C3E102B5E5 /* SetupComparisonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE36167100E59D7413C50F63 /* SetupComparisonTests.swift */; }; + 16097A41A1EF24BCF2F670DB /* Statistics/SetupComparisonCharts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174DD37A96993576E0E573C6 /* Statistics/SetupComparisonCharts.swift */; }; + 414E7082E6A6B9DA8CD11EF3 /* CurrentSetupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17C7C2227FAC0A9F7F570A01 /* CurrentSetupViewModel.swift */; }; + D7DECBB118052B85680A241B /* SetupComparison.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E7CDB17CCE70885EE93850 /* SetupComparison.swift */; }; 6D16FF26E7F84F97B5DB6A88 /* CurrentSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFD4A26A23202AF2EA506FCF /* CurrentSetupView.swift */; }; D70F014FFB67BA58678452E9 /* SetupChangeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC166B8AD05800AFCD474E /* SetupChangeTests.swift */; }; C16F511B2B89363A00EFD7A1 /* SimpleInsulinDose.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */; }; @@ -1378,6 +1382,10 @@ C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDataAlgorithmInput.swift; sourceTree = ""; }; C4C16D18C8806FF48331134C /* InsulinDeliveryHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryHistory.swift; sourceTree = ""; }; 12CD655083D4816F87B13463 /* SetupChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupChange.swift; sourceTree = ""; }; + BE36167100E59D7413C50F63 /* SetupComparisonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupComparisonTests.swift; sourceTree = ""; }; + 174DD37A96993576E0E573C6 /* Statistics/SetupComparisonCharts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Statistics/SetupComparisonCharts.swift; sourceTree = ""; }; + 17C7C2227FAC0A9F7F570A01 /* CurrentSetupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentSetupViewModel.swift; sourceTree = ""; }; + D5E7CDB17CCE70885EE93850 /* SetupComparison.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupComparison.swift; sourceTree = ""; }; CFD4A26A23202AF2EA506FCF /* CurrentSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentSetupView.swift; sourceTree = ""; }; 03EC166B8AD05800AFCD474E /* SetupChangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupChangeTests.swift; sourceTree = ""; }; C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleInsulinDose.swift; sourceTree = ""; }; @@ -1826,6 +1834,7 @@ C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */, C4C16D18C8806FF48331134C /* InsulinDeliveryHistory.swift */, 12CD655083D4816F87B13463 /* SetupChange.swift */, + D5E7CDB17CCE70885EE93850 /* SetupComparison.swift */, C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */, C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */, 84213C882D94941000642E78 /* InsulinDeliveryLogEvent.swift */, @@ -2157,6 +2166,7 @@ FEEDF0000000000000000004 /* Statistics/TimeInRangeBar.swift */, 747DA22B61531CCAAC79E86D /* Statistics/DailyInsulinChartView.swift */, FEEDF0000000000000000003 /* Statistics/AGPChartView.swift */, + 174DD37A96993576E0E573C6 /* Statistics/SetupComparisonCharts.swift */, FEEDF0000000000000000002 /* Statistics/StatisticsView.swift */, E4854073AAE5C2CB97167F63 /* HealthAccessView.swift */, DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */, @@ -2663,6 +2673,7 @@ C11BD0542523CFED00236B08 /* SimpleBolusViewModel.swift */, 1DB1CA4E24A56D7600B3B94C /* SettingsViewModel.swift */, FEEDF0000000000000000001 /* StatisticsViewModel.swift */, + 17C7C2227FAC0A9F7F570A01 /* CurrentSetupViewModel.swift */, 1D49795724E7289700948F05 /* ServicesViewModel.swift */, C174233B259BEB0F00399C9D /* ManualEntryDoseViewModel.swift */, 1DB619AB270BAD3D006C9D07 /* VersionUpdateViewModel.swift */, @@ -2766,6 +2777,7 @@ C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */, F69CE2EE3714858772FE7A26 /* InsulinDeliveryHistoryTests.swift */, 03EC166B8AD05800AFCD474E /* SetupChangeTests.swift */, + BE36167100E59D7413C50F63 /* SetupComparisonTests.swift */, FEEDF0000000000000000005 /* GlucoseStatisticsTests.swift */, A9DFAFB224F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift */, A963B279252CEBAE0062AA12 /* SetBolusUserInfoTests.swift */, @@ -3713,6 +3725,7 @@ B4F3D25124AF890C0095CE44 /* BluetoothStateManager.swift in Sources */, 1DDE273D24AEA4B000796622 /* SettingsViewModel.swift in Sources */, FEEDB0000000000000000001 /* StatisticsViewModel.swift in Sources */, + 414E7082E6A6B9DA8CD11EF3 /* CurrentSetupViewModel.swift in Sources */, DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */, A1B2C3D42E8F000100000002 /* GuardAndRunwaySelectionView.swift in Sources */, 842E40A72F22F7E2000CCCE0 /* TintedContent.swift in Sources */, @@ -3767,6 +3780,7 @@ 1D6B1B6726866D89009AC446 /* AlertPermissionsChecker.swift in Sources */, 52ECDA0F09B4C8F23A4B450E /* InsulinDeliveryHistory.swift in Sources */, 8879F70ABCDF1E10619547A4 /* SetupChange.swift in Sources */, + D7DECBB118052B85680A241B /* SetupComparison.swift in Sources */, C16F511B2B89363A00EFD7A1 /* SimpleInsulinDose.swift in Sources */, 4F08DE8F1E7BB871006741EA /* CollectionType+Loop.swift in Sources */, A9F703772489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift in Sources */, @@ -3786,6 +3800,7 @@ FEEDB0000000000000000004 /* Statistics/TimeInRangeBar.swift in Sources */, A3C9F155FD92DC188BD9A21F /* Statistics/DailyInsulinChartView.swift in Sources */, FEEDB0000000000000000003 /* Statistics/AGPChartView.swift in Sources */, + 16097A41A1EF24BCF2F670DB /* Statistics/SetupComparisonCharts.swift in Sources */, FEEDB0000000000000000002 /* Statistics/StatisticsView.swift in Sources */, BAF043BF0BC70EC99AE79F71 /* HealthAccessView.swift in Sources */, A9B607B0247F000F00792BE4 /* UserNotifications+Loop.swift in Sources */, @@ -4039,6 +4054,7 @@ C152B9F72C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift in Sources */, 30E4A68B8C914C5E4B245555 /* InsulinDeliveryHistoryTests.swift in Sources */, D70F014FFB67BA58678452E9 /* SetupChangeTests.swift in Sources */, + 2622474F63F391C3E102B5E5 /* SetupComparisonTests.swift in Sources */, FEEDB0000000000000000005 /* GlucoseStatisticsTests.swift in Sources */, A9DFAFB324F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift in Sources */, A963B27A252CEBAE0062AA12 /* SetBolusUserInfoTests.swift in Sources */, diff --git a/Loop/Models/SetupChange.swift b/Loop/Models/SetupChange.swift index bf616faf2e..0f5e2a8d96 100644 --- a/Loop/Models/SetupChange.swift +++ b/Loop/Models/SetupChange.swift @@ -23,6 +23,8 @@ struct SetupChange: Equatable { /// The setup is known to be unchanged since `date`, but may be older. var isLowerBound: Bool var items: [Item] + /// When the setup before this change started, as far as the history shows. + var previousDate: Date? = nil /// Changes this close to the latest one are reported together, as one edit. static let groupingInterval = TimeInterval(hours: 1) @@ -48,8 +50,10 @@ struct SetupChange: Equatable { guard let last = changes.last else { return SetupChange(date: first.date, isLowerBound: true, items: []) } - return changes.reversed().reduce(SetupChange(date: last.date, isLowerBound: false, items: [])) { result, change in - change.date >= last.date - groupingInterval ? result.merging(change) : result + let groupStart = last.date - groupingInterval + let previousDate = changes.last(where: { $0.date < groupStart })?.date ?? first.date + return changes.reversed().reduce(SetupChange(date: last.date, isLowerBound: false, items: [], previousDate: previousDate)) { result, change in + change.date >= groupStart ? result.merging(change) : result } } @@ -57,11 +61,18 @@ struct SetupChange: Equatable { static func latest(_ a: SetupChange?, _ b: SetupChange?) -> SetupChange? { guard let a else { return b } guard let b else { return a } - let (earlier, later) = a.date <= b.date ? (a, b) : (b, a) - guard !later.isLowerBound, !earlier.isLowerBound, earlier.date >= later.date - groupingInterval else { + var (earlier, later) = a.date <= b.date ? (a, b) : (b, a) + if later.isLowerBound { return later } - return later.merging(earlier) + guard !earlier.isLowerBound, earlier.date >= later.date - groupingInterval else { + later.previousDate = max(later.previousDate ?? earlier.date, earlier.date) + return later + } + let previousDates = [earlier.previousDate, later.previousDate].compactMap { $0 } + later = later.merging(earlier) + later.previousDate = previousDates.max() + return later } private func merging(_ other: SetupChange) -> SetupChange { diff --git a/Loop/Models/SetupComparison.swift b/Loop/Models/SetupComparison.swift new file mode 100644 index 0000000000..90e0bf1596 --- /dev/null +++ b/Loop/Models/SetupComparison.swift @@ -0,0 +1,64 @@ +// +// SetupComparison.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +/// Outcomes since the last setup change, compared with the period before it. +struct SetupComparison: Equatable { + struct Period: Equatable { + let start: Date + let end: Date + let glucose: GlucoseStatistics + /// Average insulin per day, or nil when the dose records don't cover the period. + let insulinPerDay: Double? + + var days: Double { end.timeIntervalSince(start) / .days(1) } + var timeInRange: Double? { glucose.timeInRange.isEmpty ? nil : glucose.timeInRange[.target, default: 0] } + var timeBelowRange: Double? { glucose.timeInRange.isEmpty ? nil : glucose.timeInRange[.low, default: 0] + glucose.timeInRange[.veryLow, default: 0] } + } + + static let beforeDuration = TimeInterval.days(14) + static let suggestedDuration = TimeInterval.days(14) + /// Less CGM data than this in a period makes its numbers unreliable. + static let minimumCGMActive = 0.7 + static let minimumPeriod = TimeInterval.days(1) + + let before: Period? + let after: Period + + /// The comparison is still settling until the setup has run for about two weeks. + var isSettling: Bool { after.end.timeIntervalSince(after.start) < Self.suggestedDuration } + + init?(change: SetupChange, + samples: [StoredGlucoseSample], + insulinHistory: InsulinDeliveryHistory?, + now: Date, + calendar: Calendar = .current) { + guard !change.isLowerBound, now.timeIntervalSince(change.date) >= Self.minimumPeriod else { + return nil + } + func period(_ start: Date, _ end: Date) -> Period { + Period(start: start, + end: end, + glucose: GlucoseStatistics(samples: samples, start: start, end: end, calendar: calendar), + insulinPerDay: Self.insulinPerDay(insulinHistory, from: start, to: end)) + } + after = period(change.date, now) + + let beforeStart = max(change.date - Self.beforeDuration, change.previousDate ?? .distantPast) + let candidate = period(beforeStart, change.date) + before = change.date.timeIntervalSince(beforeStart) >= Self.minimumPeriod && candidate.glucose.percentActive >= Self.minimumCGMActive ? candidate : nil + } + + private static func insulinPerDay(_ history: InsulinDeliveryHistory?, from start: Date, to end: Date) -> Double? { + guard let history, history.coverageStart <= start, end <= history.end else { + return nil + } + return history.totals(from: start, to: end).total / (end.timeIntervalSince(start) / .days(1)) + } +} diff --git a/Loop/View Models/CurrentSetupViewModel.swift b/Loop/View Models/CurrentSetupViewModel.swift new file mode 100644 index 0000000000..d8069265cc --- /dev/null +++ b/Loop/View Models/CurrentSetupViewModel.swift @@ -0,0 +1,50 @@ +// +// CurrentSetupViewModel.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +@MainActor +final class CurrentSetupViewModel: ObservableObject { + @Published private(set) var setupChange: SetupChange? + @Published private(set) var comparison: SetupComparison? + @Published private(set) var isLoading = true + + private let loadSetupChange: () async throws -> SetupChange? + private let glucoseStore: GlucoseStoreProtocol? + private let insulinDeliveryHistory: ((Int) async throws -> InsulinDeliveryHistory)? + private let now: () -> Date + + init(setupChange: SetupChange?, + loadSetupChange: @escaping () async throws -> SetupChange?, + glucoseStore: GlucoseStoreProtocol?, + insulinDeliveryHistory: ((Int) async throws -> InsulinDeliveryHistory)?, + now: @escaping () -> Date = { Date() }) { + self.setupChange = setupChange + self.loadSetupChange = loadSetupChange + self.glucoseStore = glucoseStore + self.insulinDeliveryHistory = insulinDeliveryHistory + self.now = now + } + + func load() async { + defer { isLoading = false } + if let change = try? await loadSetupChange() { + setupChange = change + } + guard let change = setupChange, !change.isLowerBound, let glucoseStore else { + return + } + let end = now() + let start = max(change.date - SetupComparison.beforeDuration, change.previousDate ?? .distantPast) + let samples = (try? await glucoseStore.getGlucoseSamples(start: start, end: end))? + .filter { !$0.isDisplayOnly && !$0.wasUserEntered } ?? [] + let days = Int((end.timeIntervalSince(start) / .days(1)).rounded(.up)) + 1 + let insulin = try? await insulinDeliveryHistory?(days) + comparison = SetupComparison(change: change, samples: samples, insulinHistory: insulin, now: end) + } +} diff --git a/Loop/Views/CurrentSetupView.swift b/Loop/Views/CurrentSetupView.swift index 08fd7742c9..c867edce19 100644 --- a/Loop/Views/CurrentSetupView.swift +++ b/Loop/Views/CurrentSetupView.swift @@ -11,10 +11,33 @@ import LoopKitUI import LoopAlgorithm struct CurrentSetupView: View { - let setupChange: SetupChange? + @StateObject private var viewModel: CurrentSetupViewModel + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + + init(viewModel: @autoclosure @escaping () -> CurrentSetupViewModel) { + _viewModel = StateObject(wrappedValue: viewModel()) + } + + private var setupChange: SetupChange? { viewModel.setupChange } var body: some View { List { + if let setupChange { + Section { + Text(summary(setupChange)) + if let comparison = viewModel.comparison { + if let before = comparison.before { + TimeInRangeStripe(title: NSLocalizedString("Before", comment: "Title of the time in range bar for the period before the setup change"), timeInRange: before.glucose.timeInRange) + } + TimeInRangeStripe(title: NSLocalizedString("Since Change", comment: "Title of the time in range bar for the period since the setup change"), timeInRange: comparison.after.glucose.timeInRange) + } else if viewModel.isLoading { + HStack { Spacer(); ProgressView(); Spacer() } + } + } + if let comparison = viewModel.comparison { + resultsSection(comparison) + } + } Section { if let setupChange { LabeledContent(NSLocalizedString("Unchanged For", comment: "Title of the row showing how long the current setup has run"), value: setupChange.durationDescription) @@ -41,6 +64,108 @@ struct CurrentSetupView: View { } .insetGroupedListStyle() .navigationTitle(NSLocalizedString("Current Setup", comment: "Title of the current setup screen")) + .task { await viewModel.load() } + } + + private func resultsSection(_ comparison: SetupComparison) -> some View { + let before = comparison.before + let after = comparison.after + return Section(header: SectionHeader(label: NSLocalizedString("Results", comment: "Header of the setup comparison results")), + footer: Text(before == nil + ? NSLocalizedString("Median glucose by time of day since the change, with its 25–75% band.", comment: "Setup comparison chart explanation without a before period") + : NSLocalizedString("Median glucose by time of day: dashed before the change, solid since, with the 25–75% band since the change.", comment: "Setup comparison chart explanation"))) { + comparisonRow(NSLocalizedString("Time in Range", comment: "Setup comparison row for time in range"), + before?.timeInRange.map(percent), after.timeInRange.map(percent)) + comparisonRow(String(format: NSLocalizedString("Time Below %@", comment: "Setup comparison row for time below the low threshold (1: glucose value, e.g. 70 mg/dL)"), lowThreshold), + before?.timeBelowRange.map(percent), after.timeBelowRange.map(percent)) + comparisonRow(NSLocalizedString("Average Glucose", comment: "Setup comparison row for average glucose"), + before?.glucose.averageGlucose.map(glucose), after.glucose.averageGlucose.map(glucose)) + comparisonRow(NSLocalizedString("Insulin per Day", comment: "Setup comparison row for average daily insulin"), + before?.insulinPerDay.map(units), after.insulinPerDay.map(units)) + if !after.glucose.agpProfile.isEmpty { + MedianComparisonChartView(before: before?.glucose.agpProfile ?? [], after: after.glucose.agpProfile, unit: displayGlucosePreference.unit) + .frame(height: 200) + .padding(.vertical, 8) + } + } + } + + private func comparisonRow(_ title: String, _ before: String?, _ after: String?) -> some View { + LabeledContent(title) { + if let before { + Text(String(format: NSLocalizedString("%1$@ → %2$@", comment: "A value before and since the setup change (1: before, 2: since)"), before, after ?? "–")) + } else { + Text(after ?? "–") + } + } + .monospacedDigit() + } + + private var lowThreshold: String { + displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 70)) + } + + private func percent(_ fraction: Double) -> String { + fraction.formatted(.percent.precision(.fractionLength(fraction < 0.1 ? 1 : 0))) + } + + private func glucose(_ mgdl: Double) -> String { + displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: mgdl)) + } + + private func units(_ value: Double) -> String { + String(format: NSLocalizedString("%@ U", comment: "Insulin amount in units (1: number)"), value.formatted(.number.precision(.fractionLength(1)))) + } + + private func summary(_ change: SetupChange) -> String { + guard !change.isLowerBound else { + return String(format: NSLocalizedString("No setup change has been recorded since %@, so there's no earlier setup to compare with.", comment: "Setup summary when the start of the setup isn't known (1: date)"), + change.date.formatted(date: .abbreviated, time: .omitted)) + } + var sentences = [change.items.isEmpty + ? String(format: NSLocalizedString("It's been %@ since your last setup change.", comment: "Setup summary first sentence (1: duration, e.g. 5 days)"), change.durationDescription) + : String(format: NSLocalizedString("It's been %1$@ since your last setup change: %2$@.", comment: "Setup summary first sentence (1: duration, e.g. 5 days, 2: list of what changed)"), change.durationDescription, ListFormatter.localizedString(byJoining: change.items.map(\.title)))] + guard let comparison = viewModel.comparison else { + if !viewModel.isLoading { + sentences.append(NSLocalizedString("Results appear after a day with the new setup.", comment: "Setup summary before a day has passed since the change")) + } + return sentences.joined(separator: " ") + } + let after = comparison.after + if let before = comparison.before, let tirBefore = before.timeInRange, let tirAfter = after.timeInRange, + let lowBefore = before.timeBelowRange, let lowAfter = after.timeBelowRange { + sentences.append(String(format: NSLocalizedString("Compared with the %1$@ before it, time in range %2$@, and time below %3$@ %4$@.", comment: "Setup comparison summary (1: length of the before period, 2: time in range change, 3: low threshold, 4: time below range change)"), + periodDescription(before.days), + changeDescription(tirBefore, tirAfter, threshold: 0.01), + lowThreshold, + changeDescription(lowBefore, lowAfter, threshold: 0.005))) + } else { + sentences.append(NSLocalizedString("There isn't enough CGM data from before the change to compare with.", comment: "Setup summary when the period before the change has too little data")) + } + if after.glucose.percentActive < SetupComparison.minimumCGMActive { + sentences.append(String(format: NSLocalizedString("CGM data covers only %@ of the time since the change.", comment: "Setup summary note about missing CGM data (1: percent)"), percent(after.glucose.percentActive))) + } + if comparison.isSettling { + sentences.append(NSLocalizedString("With less than 2 weeks so far, these numbers can still change a lot.", comment: "Setup summary note while the new setup has run less than two weeks")) + } + return sentences.joined(separator: " ") + } + + private func changeDescription(_ before: Double, _ after: Double, threshold: Double) -> String { + if abs(after - before) < threshold { + return String(format: NSLocalizedString("stayed about the same at %@", comment: "A percentage that didn't change much (1: percent)"), percent(after)) + } + let format = after > before + ? NSLocalizedString("rose from %1$@ to %2$@", comment: "A percentage that went up (1: before, 2: after)") + : NSLocalizedString("fell from %1$@ to %2$@", comment: "A percentage that went down (1: before, 2: after)") + return String(format: format, percent(before), percent(after)) + } + + private func periodDescription(_ days: Double) -> String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = days >= 13.5 && days <= 14.5 ? [.weekOfMonth] : [.day] + formatter.unitsStyle = .full + return formatter.string(from: days.rounded() * .days(1)) ?? "" } } diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index fb26cd5675..4bbff8d8cc 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -358,7 +358,12 @@ extension SettingsView { ) .accessibilityIdentifier("settingsViewStatistics") } - NavigationLink(destination: CurrentSetupView(setupChange: setupChange)) { + NavigationLink(destination: CurrentSetupView(viewModel: CurrentSetupViewModel( + setupChange: setupChange, + loadSetupChange: { [viewModel] in try await viewModel.setupChange?() }, + glucoseStore: glucoseStore, + insulinDeliveryHistory: viewModel.insulinDeliveryHistory + ))) { LargeButton( action: {}, includeArrow: false, diff --git a/Loop/Views/Statistics/AGPChartView.swift b/Loop/Views/Statistics/AGPChartView.swift index c5e1ae3c69..53db129c10 100644 --- a/Loop/Views/Statistics/AGPChartView.swift +++ b/Loop/Views/Statistics/AGPChartView.swift @@ -51,7 +51,7 @@ struct AGPChartView: View { /// Append a smooth (Catmull-Rom) curve through `points` to a path already /// positioned at `points[0]`. - private static func addSmoothCurve(through points: [CGPoint], to path: inout Path) { + static func addSmoothCurve(through points: [CGPoint], to path: inout Path) { guard points.count > 1 else { return } for i in 0..<(points.count - 1) { let p0 = points[max(0, i - 1)] diff --git a/Loop/Views/Statistics/SetupComparisonCharts.swift b/Loop/Views/Statistics/SetupComparisonCharts.swift new file mode 100644 index 0000000000..3c078947d8 --- /dev/null +++ b/Loop/Views/Statistics/SetupComparisonCharts.swift @@ -0,0 +1,121 @@ +// +// SetupComparisonCharts.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopAlgorithm + +/// A horizontal time-in-range bar for one period, lows on the left. +struct TimeInRangeStripe: View { + let title: String + let timeInRange: [GlucoseBand: Double] + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(title) + .font(.subheadline.weight(.semibold)) + Spacer() + Text(String(format: NSLocalizedString("%@ in range", comment: "Time in range for a period (1: percent)"), percent(timeInRange[.target, default: 0]))) + .font(.subheadline) + .monospacedDigit() + } + GeometryReader { geometry in + HStack(spacing: 0) { + ForEach(GlucoseBand.allCases, id: \.self) { band in + Rectangle() + .fill(band.agpColor) + .frame(width: geometry.size.width * timeInRange[band, default: 0]) + } + } + } + .frame(height: 14) + .clipShape(RoundedRectangle(cornerRadius: 4)) + Text(String(format: NSLocalizedString("%1$@ low · %2$@ high", comment: "Time below and above range for a period (1: percent below, 2: percent above)"), + percent(timeInRange[.low, default: 0] + timeInRange[.veryLow, default: 0]), + percent(timeInRange[.high, default: 0] + timeInRange[.veryHigh, default: 0]))) + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + } + .accessibilityElement(children: .combine) + } + + private func percent(_ fraction: Double) -> String { + fraction.formatted(.percent.precision(.fractionLength(fraction < 0.1 ? 1 : 0))) + } +} + +/// The median glucose by time of day before and after a setup change, with the 25–75% band since the change. +struct MedianComparisonChartView: View { + let before: [AGPBand] + let after: [AGPBand] + let unit: LoopUnit + + private let leftInset: CGFloat = 34 + private let bottomInset: CGFloat = 18 + private let topInset: CGFloat = 6 + private let rightInset: CGFloat = 6 + + private var topMgDL: Double { max(300, ((before + after).map(\.p75).max() ?? 300).rounded(.up)) } + + private func yLabel(_ mgdl: Double) -> String { + let v = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: mgdl).doubleValue(for: unit) + return unit == .milligramsPerDeciliter ? "\(Int(v.rounded()))" : String(format: "%.1f", v) + } + + var body: some View { + Canvas { ctx, size in + let plot = CGRect(x: leftInset, y: topInset, + width: size.width - leftInset - rightInset, + height: size.height - topInset - bottomInset) + guard plot.width > 0, plot.height > 0 else { return } + let top = topMgDL + + func x(_ band: AGPBand) -> CGFloat { plot.minX + CGFloat(band.timeOfDay / .days(1)) * plot.width } + func y(_ mgdl: Double) -> CGFloat { plot.minY + CGFloat(1 - mgdl / top) * plot.height } + func curve(_ bands: [AGPBand], _ value: (AGPBand) -> Double) -> Path { + let points = bands.map { CGPoint(x: x($0), y: y(value($0))) } + var path = Path() + if let first = points.first { + path.move(to: first) + AGPChartView.addSmoothCurve(through: points, to: &path) + } + return path + } + + for v in [70.0, 180] { + var line = Path() + line.move(to: CGPoint(x: plot.minX, y: y(v))) + line.addLine(to: CGPoint(x: plot.maxX, y: y(v))) + ctx.stroke(line, with: .color(GlucoseBand.target.agpColor), lineWidth: 1) + } + + let afterSorted = after.sorted { $0.timeOfDay < $1.timeOfDay } + if afterSorted.count > 1 { + var band = curve(afterSorted, \.p75) + let lower = afterSorted.reversed().map { CGPoint(x: x($0), y: y($0.p25)) } + band.addLine(to: lower[0]) + AGPChartView.addSmoothCurve(through: lower, to: &band) + band.closeSubpath() + ctx.fill(band, with: .color(Color.accentColor.opacity(0.15))) + } + ctx.stroke(curve(before.sorted { $0.timeOfDay < $1.timeOfDay }, \.p50), with: .color(.secondary), style: StrokeStyle(lineWidth: 2, dash: [5, 4])) + ctx.stroke(curve(afterSorted, \.p50), with: .color(.accentColor), lineWidth: 2.5) + + for v in [70.0, 180, top] { + ctx.draw(Text(yLabel(v)).font(.caption2).foregroundColor(.secondary), + at: CGPoint(x: plot.minX - 4, y: y(v)), anchor: .trailing) + } + for (hour, label) in [(0, "12a"), (6, "6a"), (12, "12p"), (18, "6p"), (24, "12a")] { + ctx.draw(Text(label).font(.caption2).foregroundColor(.secondary), + at: CGPoint(x: plot.minX + CGFloat(hour) / 24 * plot.width, y: plot.maxY + 9), anchor: .center) + } + } + .accessibilityLabel(Text(NSLocalizedString("Median glucose by time of day, before and since the change", comment: "Accessibility label for the setup comparison chart"))) + } +} diff --git a/LoopTests/Models/SetupChangeTests.swift b/LoopTests/Models/SetupChangeTests.swift index 1cd0b15de0..ceda1677d7 100644 --- a/LoopTests/Models/SetupChangeTests.swift +++ b/LoopTests/Models/SetupChangeTests.swift @@ -47,13 +47,14 @@ final class SetupChangeTests: XCTestCase { func testLatestChangeWins() { let change = SetupChange.latest(in: [settings(at: 0), settings(at: 10, basal: 1.2), settings(at: 20, basal: 1.2, maximumBolus: 8)]) - XCTAssertEqual(change, SetupChange(date: start.addingTimeInterval(.hours(20)), isLowerBound: false, items: [.therapySetting(.deliveryLimits)])) + XCTAssertEqual(change, SetupChange(date: start.addingTimeInterval(.hours(20)), isLowerBound: false, items: [.therapySetting(.deliveryLimits)], previousDate: start.addingTimeInterval(.hours(10)))) } func testChangesWithinAnHourAreGrouped() { let change = SetupChange.latest(in: [settings(at: 0), settings(at: 10, isf: 45), settings(at: 10.5, isf: 45, strategy: .tempBasalOnly)]) XCTAssertEqual(change?.date, start.addingTimeInterval(.hours(10.5))) XCTAssertEqual(change?.items, [.dosingStrategy, .therapySetting(.insulinSensitivity)]) + XCTAssertEqual(change?.previousDate, start) } func testIgnoredChanges() { @@ -84,7 +85,8 @@ final class SetupChangeTests: XCTestCase { func testExperimentChangeAfterSettingsChange() { let settingsChange = SetupChange(date: start, isLowerBound: false, items: [.therapySetting(.carbRatio)]) let experimentChange = SetupChange(date: start.addingTimeInterval(.hours(30)), isLowerBound: false, items: [.experiment(.guardAndRunway, enabled: true)]) - XCTAssertEqual(SetupChange.latest(settingsChange, experimentChange), experimentChange) + XCTAssertEqual(SetupChange.latest(settingsChange, experimentChange)?.previousDate, start) + XCTAssertEqual(SetupChange.latest(settingsChange, experimentChange)?.items, experimentChange.items) } func testCloseChangesAreMerged() { @@ -102,6 +104,7 @@ final class SetupChangeTests: XCTestCase { func testChangeAfterLowerBoundWins() { let settingsBound = SetupChange(date: start, isLowerBound: true, items: []) let experimentChange = SetupChange(date: start.addingTimeInterval(.minutes(20)), isLowerBound: false, items: [.experiment(.integralRetrospectiveCorrection, enabled: false)]) - XCTAssertEqual(SetupChange.latest(settingsBound, experimentChange), experimentChange) + XCTAssertEqual(SetupChange.latest(settingsBound, experimentChange)?.date, experimentChange.date) + XCTAssertEqual(SetupChange.latest(settingsBound, experimentChange)?.previousDate, start) } } diff --git a/LoopTests/Models/SetupComparisonTests.swift b/LoopTests/Models/SetupComparisonTests.swift new file mode 100644 index 0000000000..0fbe158238 --- /dev/null +++ b/LoopTests/Models/SetupComparisonTests.swift @@ -0,0 +1,108 @@ +// +// SetupComparisonTests.swift +// LoopTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +import LoopKit +import LoopAlgorithm +@testable import Loop + +final class SetupComparisonTests: XCTestCase { + private let changeDate = Date(timeIntervalSinceReferenceDate: 800_000_000) + + /// Readings every 5 minutes; `value` gives mg/dL for each reading's date. + private func samples(from start: Date, to end: Date, value: (Date) -> Double) -> [StoredGlucoseSample] { + stride(from: start.timeIntervalSinceReferenceDate, to: end.timeIntervalSinceReferenceDate, by: .minutes(5)).map { + let date = Date(timeIntervalSinceReferenceDate: $0) + return StoredGlucoseSample(startDate: date, quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: value(date))) + } + } + + private func change(previousDate: Date? = nil) -> SetupChange { + SetupChange(date: changeDate, isLowerBound: false, items: [.therapySetting(.carbRatio)], previousDate: previousDate) + } + + func testBeforeAndAfter() throws { + let now = changeDate.addingTimeInterval(.days(5)) + // Before: all in range. After: one reading in ten is low. + var index = 0 + let glucose = samples(from: changeDate - .days(20), to: now) { date in + index += 1 + return date < self.changeDate ? 120 : (index % 10 == 0 ? 60 : 120) + } + let comparison = try XCTUnwrap(SetupComparison(change: change(), samples: glucose, insulinHistory: nil, now: now)) + let before = try XCTUnwrap(comparison.before) + + XCTAssertEqual(before.start, changeDate - .days(14)) + XCTAssertEqual(before.timeInRange ?? 0, 1, accuracy: 0.001) + XCTAssertEqual(before.timeBelowRange ?? 1, 0, accuracy: 0.001) + XCTAssertEqual(comparison.after.start, changeDate) + XCTAssertEqual(comparison.after.timeBelowRange ?? 0, 0.1, accuracy: 0.01) + XCTAssertTrue(comparison.isSettling) + } + + func testBeforeStopsAtPreviousChange() throws { + let now = changeDate.addingTimeInterval(.days(15)) + let glucose = samples(from: changeDate - .days(20), to: now) { _ in 120 } + let comparison = try XCTUnwrap(SetupComparison(change: change(previousDate: changeDate - .days(4)), samples: glucose, insulinHistory: nil, now: now)) + + XCTAssertEqual(comparison.before?.start, changeDate - .days(4)) + XCTAssertEqual(comparison.before?.days ?? 0, 4, accuracy: 0.001) + XCTAssertFalse(comparison.isSettling) + } + + func testNoBeforeWhenThePreviousSetupWasShort() throws { + let now = changeDate.addingTimeInterval(.days(3)) + let glucose = samples(from: changeDate - .days(20), to: now) { _ in 120 } + let comparison = try XCTUnwrap(SetupComparison(change: change(previousDate: changeDate - .hours(12)), samples: glucose, insulinHistory: nil, now: now)) + XCTAssertNil(comparison.before) + } + + func testNoBeforeWithoutEnoughCGMData() throws { + let now = changeDate.addingTimeInterval(.days(3)) + let glucose = samples(from: changeDate - .days(2), to: now) { _ in 120 } + let comparison = try XCTUnwrap(SetupComparison(change: change(), samples: glucose, insulinHistory: nil, now: now)) + XCTAssertNil(comparison.before) + } + + func testNothingUntilADayHasPassed() { + let now = changeDate.addingTimeInterval(.hours(20)) + let glucose = samples(from: changeDate - .days(14), to: now) { _ in 120 } + XCTAssertNil(SetupComparison(change: change(), samples: glucose, insulinHistory: nil, now: now)) + } + + func testNothingForALowerBound() { + let now = changeDate.addingTimeInterval(.days(20)) + let bound = SetupChange(date: changeDate, isLowerBound: true, items: []) + XCTAssertNil(SetupComparison(change: bound, samples: [], insulinHistory: nil, now: now)) + } + + func testInsulinPerDay() throws { + let now = changeDate.addingTimeInterval(.days(2)) + let start = changeDate - .days(14) + let glucose = samples(from: start, to: now) { _ in 120 } + let bolus = SimpleInsulinDose(deliveryType: .bolus, automatic: false, startDate: changeDate + .hours(1), endDate: changeDate + .hours(1), volume: 4, insulinModel: ExponentialInsulinModelPreset.rapidActingAdult) + let history = InsulinDeliveryHistory( + doses: [bolus], + scheduledBasal: [AbsoluteScheduleValue(startDate: start, endDate: now, value: 1.0)], + coverageStart: start, + end: now) + let comparison = try XCTUnwrap(SetupComparison(change: change(), samples: glucose, insulinHistory: history, now: now)) + + XCTAssertEqual(comparison.before?.insulinPerDay ?? 0, 24, accuracy: 0.001) + XCTAssertEqual(comparison.after.insulinPerDay ?? 0, 26, accuracy: 0.001) + } + + func testNoInsulinWhenDosesDontCoverThePeriod() throws { + let now = changeDate.addingTimeInterval(.days(2)) + let glucose = samples(from: changeDate - .days(14), to: now) { _ in 120 } + let history = InsulinDeliveryHistory(doses: [], scheduledBasal: [], coverageStart: changeDate - .days(1), end: now) + let comparison = try XCTUnwrap(SetupComparison(change: change(), samples: glucose, insulinHistory: history, now: now)) + + XCTAssertNil(comparison.before?.insulinPerDay) + XCTAssertNotNil(comparison.after.insulinPerDay) + } +} From 4efd45f52e0301a7dbc8432975b4775b2094769c Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 27 Oct 2026 00:41:06 -0500 Subject: [PATCH 4/6] Find setup changes in the dosing decisions as well as the settings history Experiment changes now come from the experiments recorded on each dosing decision instead of a date saved when a switch is flipped. That covers changes made before this build, back to the builds that started recording experiments, so an experiment that's on no longer counts from the first launch as "at least". It also uses the experiments actually in effect, so Glucose Based Partial Application turns off with automatic bolus. SetupHistory lists every change from both sources, oldest first, grouping changes within an hour, each with the date of the change before it. The experiment transitions read from dosing decisions are cached, so each decision is read once. Needs LoopKit/LoopKit#669. --- Loop/Managers/LoopAppManager.swift | 12 +- Loop/Models/SetupChange.swift | 142 +++++++++++++++--- Loop/View Models/SettingsViewModel.swift | 2 +- ...ingsView+algorithmExperimentsSection.swift | 37 ----- Loop/Views/SettingsView.swift | 2 +- LoopTests/Models/SetupChangeTests.swift | 96 ++++++++---- 6 files changed, 197 insertions(+), 94 deletions(-) diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index e144da6f12..3e1110d169 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -495,7 +495,6 @@ class LoopAppManager: NSObject { ) } analyticsServicesManager.identifyState() - UserDefaults.standard.updateAlgorithmExperimentsChangeDate() if let pumpManager = deviceDataManager.pumpManager { analyticsServicesManager.identifyPumpType(pumpManager.pluginIdentifier) analyticsServicesManager.identifyPumpModel(pumpManager.status.device.model) @@ -610,9 +609,14 @@ class LoopAppManager: NSObject { guard let loopDataManager else { throw CancellationError() } return try await loopDataManager.insulinDeliveryHistory(days: days) } - viewModel.setupChange = { [weak settingsManager] in - let history = try await settingsManager?.settingsStore?.getStoredSettings() ?? [] - return SetupChange.latest(SetupChange.latest(in: history), UserDefaults.standard.algorithmExperimentsChange) + viewModel.setupHistory = { [weak settingsManager, weak dosingDecisionStore] in + let settings = try await settingsManager?.settingsStore?.getStoredSettings() ?? [] + let now = Date() + let start = settings.map(\.date).min() ?? now + let experiments = try await ExperimentsHistoryCache().records(since: start, now: now) { readFrom in + try await dosingDecisionStore?.getDosingDecisions(start: readFrom, end: now, as: ExperimentsRecord.self) ?? [] + } + return SetupHistory(settings: settings, experiments: experiments) } return viewModel diff --git a/Loop/Models/SetupChange.swift b/Loop/Models/SetupChange.swift index bf616faf2e..11ea38be7b 100644 --- a/Loop/Models/SetupChange.swift +++ b/Loop/Models/SetupChange.swift @@ -9,7 +9,7 @@ import Foundation import LoopKit import LoopAlgorithm -/// The most recent change to the settings that shape dosing, used to tell how long the current setup has run. +/// A change to the settings that shape dosing, used to tell how long a setup ran. struct SetupChange: Equatable { enum Item: Equatable { case therapySetting(TherapySetting) @@ -23,13 +23,14 @@ struct SetupChange: Equatable { /// The setup is known to be unchanged since `date`, but may be older. var isLowerBound: Bool var items: [Item] + /// When the setup before this change started, as far as the records show. + var previousDate: Date? = nil - /// Changes this close to the latest one are reported together, as one edit. + /// Changes this close together are reported as one edit. static let groupingInterval = TimeInterval(hours: 1) - /// Finds the latest change in a settings history, in any order. Without one, the setup is - /// unchanged since at least the oldest record. - static func latest(in history: [StoredSettings]) -> SetupChange? { + /// Changes between consecutive records of a settings history, in any order. + static func changes(in history: [StoredSettings]) -> [SetupChange] { // The insulin type comes from the pump, which has none while it's being replaced var insulinType: InsulinType? let sorted = history.sorted { $0.date < $1.date }.map { settings in @@ -38,33 +39,35 @@ struct SetupChange: Equatable { insulinType = settings.insulinType return settings } - guard let first = sorted.first else { - return nil - } - let changes = zip(sorted, sorted.dropFirst()).compactMap { previous, current -> SetupChange? in + return zip(sorted, sorted.dropFirst()).compactMap { previous, current in let items = differences(from: previous, to: current) return items.isEmpty ? nil : SetupChange(date: current.date, isLowerBound: false, items: items) } - guard let last = changes.last else { - return SetupChange(date: first.date, isLowerBound: true, items: []) - } - return changes.reversed().reduce(SetupChange(date: last.date, isLowerBound: false, items: [])) { result, change in - change.date >= last.date - groupingInterval ? result.merging(change) : result - } } - /// The later of two changes, with both reported when they're close together. - static func latest(_ a: SetupChange?, _ b: SetupChange?) -> SetupChange? { - guard let a else { return b } - guard let b else { return a } - let (earlier, later) = a.date <= b.date ? (a, b) : (b, a) - guard !later.isLowerBound, !earlier.isLowerBound, earlier.date >= later.date - groupingInterval else { - return later - } - return later.merging(earlier) + /// Experiments turned on or off between dosing decisions. Decisions from builds that didn't + /// record experiments are skipped. + static func changes(in records: [ExperimentsRecord]) -> [SetupChange] { + var previous: Set? + var changes: [SetupChange] = [] + for record in records.sorted(by: { $0.date < $1.date }) { + guard let recorded = record.algorithmExperiments else { + continue + } + let current = Set(recorded.compactMap { ExperimentOption(rawValue: $0.identifier) }) + if let previous, previous != current { + let items = ExperimentOption.allCases + .filter { previous.contains($0) != current.contains($0) } + .map { Item.experiment($0, enabled: current.contains($0)) } + changes.append(SetupChange(date: record.date, isLowerBound: false, items: items)) + } + previous = current + } + return changes } - private func merging(_ other: SetupChange) -> SetupChange { + /// Adds an earlier change's items after this one's, keeping the later date. + func merging(_ other: SetupChange) -> SetupChange { var merged = self merged.date = max(date, other.date) for item in other.items where !merged.items.contains(item) { @@ -138,3 +141,92 @@ struct SetupChange: Equatable { return abs(x - y) <= tolerance * max(abs(x), abs(y)) } } + +/// The date of a dosing decision and the experiments it ran with, decoded without the rest of the decision. +struct ExperimentsRecord: Codable, Equatable { + let date: Date + /// Nil for builds that didn't record experiments. + let algorithmExperiments: [AlgorithmExperiment]? + + /// The first record with experiments, then each record where they changed. + static func transitions(_ records: [ExperimentsRecord]) -> [ExperimentsRecord] { + var kept: [ExperimentsRecord] = [] + var previous: Set? + for record in records.sorted(by: { $0.date < $1.date }) { + guard let experiments = record.algorithmExperiments else { + continue + } + let identifiers = Set(experiments.map(\.identifier)) + if identifiers != previous { + kept.append(ExperimentsRecord(date: record.date, algorithmExperiments: experiments.map { AlgorithmExperiment(identifier: $0.identifier) })) + previous = identifiers + } + } + return kept + } +} + +/// The experiment transitions read from dosing decisions so far, so each decision is read once. +struct ExperimentsHistoryCache { + private struct Stored: Codable { + var records: [ExperimentsRecord] + var readThrough: Date + } + + private let defaults: UserDefaults + private let key = "com.loopkit.Loop.experimentsHistory" + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + /// Reads the decisions since the last call with `read(start)` and returns every transition since `start`. + func records(since start: Date, now: Date, read: (Date) async throws -> [ExperimentsRecord]) async throws -> [ExperimentsRecord] { + let stored = defaults.data(forKey: key).flatMap { try? PropertyListDecoder().decode(Stored.self, from: $0) } + let readFrom = max(start, stored?.readThrough ?? start) + let records = ExperimentsRecord.transitions((stored?.records ?? []) + (try await read(readFrom))) + // Keep the last record before `start`, which says what was in effect then + let current = records.filter { $0.date >= start } + let kept = (records.last { $0.date < start }.map { [$0] } ?? []) + current + defaults.set(try? PropertyListEncoder().encode(Stored(records: kept, readThrough: now)), forKey: key) + return kept + } +} + +/// Every setup change in Loop's records, from the settings history and the experiments recorded +/// on dosing decisions. +struct SetupHistory: Equatable { + /// Oldest first, each with the date of the change before it. + let changes: [SetupChange] + /// The oldest record. The setup before the first change is known only since then. + let start: Date? + + init(settings: [StoredSettings], experiments: [ExperimentsRecord]) { + let individual = (SetupChange.changes(in: settings) + SetupChange.changes(in: experiments)).sorted { $0.date < $1.date } + var groups: [SetupChange] = [] + for change in individual { + if let last = groups.last, change.date <= last.date + SetupChange.groupingInterval { + groups[groups.count - 1] = change.merging(last) + } else { + groups.append(change) + } + } + let start = (settings.map(\.date) + experiments.filter { $0.algorithmExperiments != nil }.map(\.date)).min() + self.start = start + changes = groups.indices.map { index in + var change = groups[index] + change.previousDate = index > 0 ? groups[index - 1].date : start + return change + } + } + + /// The latest change, or without one, a lower bound at the oldest record. + var latest: SetupChange? { + changes.last ?? start.map { SetupChange(date: $0, isLowerBound: true, items: []) } + } + + /// When the setup that `change` started was replaced, or nil if it's current. + func end(of change: SetupChange) -> Date? { + changes.first { $0.date > change.date }?.date + } +} diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 1aef55c5c9..87c5cb424d 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -121,7 +121,7 @@ class SettingsViewModel { @ObservationIgnored var insulinDeliveryHistory: ((Int) async throws -> InsulinDeliveryHistory)? - @ObservationIgnored var setupChange: (() async throws -> SetupChange?)? + @ObservationIgnored var setupHistory: (() async throws -> SetupHistory)? @MainActor var showDeleteTestData: Bool { diff --git a/Loop/Views/SettingsView+algorithmExperimentsSection.swift b/Loop/Views/SettingsView+algorithmExperimentsSection.swift index 33e7bb84d3..770e703aa5 100644 --- a/Loop/Views/SettingsView+algorithmExperimentsSection.swift +++ b/Loop/Views/SettingsView+algorithmExperimentsSection.swift @@ -10,7 +10,6 @@ import Foundation import SwiftUI import LoopKit import LoopKitUI -import LoopAlgorithm extension SettingsView { internal var algorithmExperimentsSection: some View { @@ -86,9 +85,6 @@ public struct ExperimentsSettingsView: View { .padding() } .navigationBarTitleDisplayMode(.inline) - .onChange(of: isGuardAndRunwayEnabled) { UserDefaults.standard.recordAlgorithmExperimentChange(.guardAndRunway, enabled: isGuardAndRunwayEnabled) } - .onChange(of: isGlucoseBasedApplicationFactorEnabled) { UserDefaults.standard.recordAlgorithmExperimentChange(.glucoseBasedApplicationFactor, enabled: isGlucoseBasedApplicationFactorEnabled) } - .onChange(of: isIntegralRetrospectiveCorrectionEnabled) { UserDefaults.standard.recordAlgorithmExperimentChange(.integralRetrospectiveCorrection, enabled: isIntegralRetrospectiveCorrectionEnabled) } } } @@ -98,39 +94,6 @@ extension UserDefaults { case GlucoseBasedApplicationFactorEnabled = "com.loopkit.algorithmExperiments.glucoseBasedApplicationFactorEnabled" case IntegralRetrospectiveCorrectionEnabled = "com.loopkit.algorithmExperiments.integralRetrospectiveCorrectionEnabled" case GuardAndRunwayEnabled = "com.loopkit.algorithmExperiments.guardAndRunwayEnabled" - case ChangeDate = "com.loopkit.algorithmExperiments.changeDate" - case ChangeDateIsLowerBound = "com.loopkit.algorithmExperiments.changeDateIsLowerBound" - case ChangedExperiment = "com.loopkit.algorithmExperiments.changedExperiment" - case ChangedExperimentEnabled = "com.loopkit.algorithmExperiments.changedExperimentEnabled" - } - - var algorithmExperimentsChange: SetupChange? { - guard let date = object(forKey: Key.ChangeDate.rawValue) as? Date else { - return nil - } - var items: [SetupChange.Item] = [] - if let experiment = string(forKey: Key.ChangedExperiment.rawValue).flatMap(ExperimentOption.init(rawValue:)) { - items.append(.experiment(experiment, enabled: bool(forKey: Key.ChangedExperimentEnabled.rawValue))) - } - return SetupChange(date: date, isLowerBound: bool(forKey: Key.ChangeDateIsLowerBound.rawValue), items: items) - } - - fileprivate func recordAlgorithmExperimentChange(_ experiment: ExperimentOption, enabled: Bool) { - set(Date(), forKey: Key.ChangeDate.rawValue) - removeObject(forKey: Key.ChangeDateIsLowerBound.rawValue) - set(experiment.rawValue, forKey: Key.ChangedExperiment.rawValue) - set(enabled, forKey: Key.ChangedExperimentEnabled.rawValue) - } - - // An experiment that was on before changes were recorded counts from the first launch that sees it - func updateAlgorithmExperimentsChangeDate() { - guard object(forKey: Key.ChangeDate.rawValue) == nil, - guardAndRunwayEnabled || glucoseBasedApplicationFactorEnabled || integralRetrospectiveCorrectionEnabled - else { - return - } - set(Date(), forKey: Key.ChangeDate.rawValue) - set(true, forKey: Key.ChangeDateIsLowerBound.rawValue) } var glucoseBasedApplicationFactorEnabled: Bool { diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index fb26cd5675..09afc2870b 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -372,7 +372,7 @@ extension SettingsView { .accessibilityIdentifier("settingsViewCurrentSetup") } .task { - setupChange = try? await viewModel.setupChange?() + setupChange = try? await viewModel.setupHistory?().latest } } } diff --git a/LoopTests/Models/SetupChangeTests.swift b/LoopTests/Models/SetupChangeTests.swift index 1cd0b15de0..d95c296493 100644 --- a/LoopTests/Models/SetupChangeTests.swift +++ b/LoopTests/Models/SetupChangeTests.swift @@ -36,72 +36,116 @@ final class SetupChangeTests: XCTestCase { automaticDosingStrategy: strategy) } + private func experiments(at hours: Double, _ identifiers: [String]?) -> ExperimentsRecord { + ExperimentsRecord(date: start.addingTimeInterval(.hours(hours)), algorithmExperiments: identifiers?.map { AlgorithmExperiment(identifier: $0) }) + } + + private func history(_ settings: [StoredSettings], _ experiments: [ExperimentsRecord] = []) -> SetupHistory { + SetupHistory(settings: settings, experiments: experiments) + } + func testNoChangeIsLowerBoundAtOldestRecord() { - let change = SetupChange.latest(in: [settings(at: 5), settings(at: 0), settings(at: 10)]) + let change = history([settings(at: 5), settings(at: 0), settings(at: 10)]).latest XCTAssertEqual(change, SetupChange(date: start, isLowerBound: true, items: [])) } func testEmptyHistory() { - XCTAssertNil(SetupChange.latest(in: [])) + XCTAssertNil(history([]).latest) } - func testLatestChangeWins() { - let change = SetupChange.latest(in: [settings(at: 0), settings(at: 10, basal: 1.2), settings(at: 20, basal: 1.2, maximumBolus: 8)]) - XCTAssertEqual(change, SetupChange(date: start.addingTimeInterval(.hours(20)), isLowerBound: false, items: [.therapySetting(.deliveryLimits)])) + func testEveryChangeWithItsPreviousDate() { + let history = history([settings(at: 0), settings(at: 10, basal: 1.2), settings(at: 20, basal: 1.2, maximumBolus: 8)]) + XCTAssertEqual(history.changes, [ + SetupChange(date: start.addingTimeInterval(.hours(10)), isLowerBound: false, items: [.therapySetting(.basalRate)], previousDate: start), + SetupChange(date: start.addingTimeInterval(.hours(20)), isLowerBound: false, items: [.therapySetting(.deliveryLimits)], previousDate: start.addingTimeInterval(.hours(10))), + ]) + XCTAssertEqual(history.end(of: history.changes[0]), start.addingTimeInterval(.hours(20))) + XCTAssertNil(history.end(of: history.changes[1])) } func testChangesWithinAnHourAreGrouped() { - let change = SetupChange.latest(in: [settings(at: 0), settings(at: 10, isf: 45), settings(at: 10.5, isf: 45, strategy: .tempBasalOnly)]) + let change = history([settings(at: 0), settings(at: 10, isf: 45), settings(at: 10.5, isf: 45, strategy: .tempBasalOnly)]).latest XCTAssertEqual(change?.date, start.addingTimeInterval(.hours(10.5))) XCTAssertEqual(change?.items, [.dosingStrategy, .therapySetting(.insulinSensitivity)]) + XCTAssertEqual(change?.previousDate, start) } func testIgnoredChanges() { let other = TimeZone(identifier: "Europe/Paris")! - let change = SetupChange.latest(in: [ + let change = history([ settings(at: 0), settings(at: 1, dosingEnabled: false), settings(at: 2, deviceToken: "abc"), settings(at: 3, timeZone: other), settings(at: 4, insulinType: nil), settings(at: 5), - ]) + ]).latest XCTAssertEqual(change, SetupChange(date: start, isLowerBound: true, items: [])) } func testInsulinTypeChange() { - let change = SetupChange.latest(in: [settings(at: 0), settings(at: 1, insulinType: nil), settings(at: 2, insulinType: .fiasp)]) + let change = history([settings(at: 0), settings(at: 1, insulinType: nil), settings(at: 2, insulinType: .fiasp)]).latest XCTAssertEqual(change?.items, [.insulinType]) XCTAssertEqual(change?.date, start.addingTimeInterval(.hours(2))) } func testSameSensitivityInAnotherUnitIsNotAChange() { let mmol = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 50).doubleValue(for: .millimolesPerLiter) - let change = SetupChange.latest(in: [settings(at: 0), settings(at: 1, isf: (mmol * 10).rounded() / 10, isfUnit: .millimolesPerLiter)]) + let change = history([settings(at: 0), settings(at: 1, isf: (mmol * 10).rounded() / 10, isfUnit: .millimolesPerLiter)]).latest XCTAssertEqual(change?.isLowerBound, true) } - func testExperimentChangeAfterSettingsChange() { - let settingsChange = SetupChange(date: start, isLowerBound: false, items: [.therapySetting(.carbRatio)]) - let experimentChange = SetupChange(date: start.addingTimeInterval(.hours(30)), isLowerBound: false, items: [.experiment(.guardAndRunway, enabled: true)]) - XCTAssertEqual(SetupChange.latest(settingsChange, experimentChange), experimentChange) + func testExperimentChangesFromDosingDecisions() { + let history = history([settings(at: 0)], [ + experiments(at: 1, nil), + experiments(at: 2, []), + experiments(at: 3, []), + experiments(at: 4, ["guardAndRunway"]), + experiments(at: 5, ["guardAndRunway", "unknownExperiment"]), + experiments(at: 6, nil), + experiments(at: 8, ["integralRetrospectiveCorrection"]), + ]) + XCTAssertEqual(history.changes, [ + SetupChange(date: start.addingTimeInterval(.hours(4)), isLowerBound: false, items: [.experiment(.guardAndRunway, enabled: true)], previousDate: start), + SetupChange(date: start.addingTimeInterval(.hours(8)), isLowerBound: false, items: [.experiment(.guardAndRunway, enabled: false), .experiment(.integralRetrospectiveCorrection, enabled: true)], previousDate: start.addingTimeInterval(.hours(4))), + ]) + } + + func testCloseSettingsAndExperimentChangesAreGrouped() { + let change = history( + [settings(at: 0), settings(at: 10, strategy: .tempBasalOnly)], + [experiments(at: 9, ["glucoseBasedApplicationFactor"]), experiments(at: 10.1, [])] + ).latest + XCTAssertEqual(change?.date, start.addingTimeInterval(.hours(10.1))) + XCTAssertEqual(change?.items, [.experiment(.glucoseBasedApplicationFactor, enabled: false), .dosingStrategy]) } - func testCloseChangesAreMerged() { - let settingsChange = SetupChange(date: start, isLowerBound: false, items: [.therapySetting(.carbRatio)]) - let experimentChange = SetupChange(date: start.addingTimeInterval(.minutes(20)), isLowerBound: false, items: [.experiment(.guardAndRunway, enabled: true)]) - XCTAssertEqual(SetupChange.latest(settingsChange, experimentChange)?.items, [.experiment(.guardAndRunway, enabled: true), .therapySetting(.carbRatio)]) + func testTransitionsKeepOnlyChanges() { + let records = [experiments(at: 0, nil), experiments(at: 1, []), experiments(at: 2, []), experiments(at: 3, ["guardAndRunway"]), experiments(at: 4, ["guardAndRunway"])] + XCTAssertEqual(ExperimentsRecord.transitions(records), [experiments(at: 1, []), experiments(at: 3, ["guardAndRunway"])]) } - func testLaterLowerBoundWins() { - let settingsChange = SetupChange(date: start, isLowerBound: false, items: [.therapySetting(.carbRatio)]) - let experimentsBound = SetupChange(date: start.addingTimeInterval(.days(3)), isLowerBound: true, items: []) - XCTAssertEqual(SetupChange.latest(settingsChange, experimentsBound), experimentsBound) + func testCacheReadsEachDecisionOnce() async throws { + let defaults = UserDefaults(suiteName: #function)! + defaults.removePersistentDomain(forName: #function) + let cache = ExperimentsHistoryCache(defaults: defaults) + let all = [experiments(at: 1, []), experiments(at: 2, []), experiments(at: 3, ["guardAndRunway"]), experiments(at: 5, [])] + var reads: [Date] = [] + func read(_ from: Date, until end: Date) -> [ExperimentsRecord] { + reads.append(from) + return all.filter { $0.date >= from && $0.date < end } + } + + let first = try await cache.records(since: start, now: start.addingTimeInterval(.hours(4))) { read($0, until: start.addingTimeInterval(.hours(4))) } + XCTAssertEqual(first, [experiments(at: 1, []), experiments(at: 3, ["guardAndRunway"])]) + + let second = try await cache.records(since: start.addingTimeInterval(.hours(2)), now: start.addingTimeInterval(.hours(6))) { read($0, until: start.addingTimeInterval(.hours(6))) } + XCTAssertEqual(reads, [start, start.addingTimeInterval(.hours(4))]) + XCTAssertEqual(second, [experiments(at: 1, []), experiments(at: 3, ["guardAndRunway"]), experiments(at: 5, [])]) } - func testChangeAfterLowerBoundWins() { - let settingsBound = SetupChange(date: start, isLowerBound: true, items: []) - let experimentChange = SetupChange(date: start.addingTimeInterval(.minutes(20)), isLowerBound: false, items: [.experiment(.integralRetrospectiveCorrection, enabled: false)]) - XCTAssertEqual(SetupChange.latest(settingsBound, experimentChange), experimentChange) + func testStartIgnoresDecisionsWithoutExperiments() { + let history = history([settings(at: 5)], [experiments(at: 1, nil), experiments(at: 3, [])]) + XCTAssertEqual(history.start, start.addingTimeInterval(.hours(3))) } } From e97219accb195c6a689349a0d19c69d92f7db01a Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 27 Oct 2026 08:29:25 -0500 Subject: [PATCH 5/6] Label the change from onboarding as the initial setup --- Loop/Models/SetupChange.swift | 6 +++++- Loop/Views/CurrentSetupView.swift | 17 +++++++++++++++-- Loop/Views/SettingsView.swift | 2 +- LoopTests/Models/SetupChangeTests.swift | 6 ++++++ 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/Loop/Models/SetupChange.swift b/Loop/Models/SetupChange.swift index 11ea38be7b..a724b099fe 100644 --- a/Loop/Models/SetupChange.swift +++ b/Loop/Models/SetupChange.swift @@ -25,6 +25,8 @@ struct SetupChange: Equatable { var items: [Item] /// When the setup before this change started, as far as the records show. var previousDate: Date? = nil + /// The therapy settings were first entered, during onboarding. + var isInitialSetup = false /// Changes this close together are reported as one edit. static let groupingInterval = TimeInterval(hours: 1) @@ -41,7 +43,8 @@ struct SetupChange: Equatable { } return zip(sorted, sorted.dropFirst()).compactMap { previous, current in let items = differences(from: previous, to: current) - return items.isEmpty ? nil : SetupChange(date: current.date, isLowerBound: false, items: items) + let isInitialSetup = previous.basalRateSchedule == nil && previous.insulinSensitivitySchedule == nil && previous.carbRatioSchedule == nil + return items.isEmpty ? nil : SetupChange(date: current.date, isLowerBound: false, items: items, isInitialSetup: isInitialSetup) } } @@ -70,6 +73,7 @@ struct SetupChange: Equatable { func merging(_ other: SetupChange) -> SetupChange { var merged = self merged.date = max(date, other.date) + merged.isInitialSetup = isInitialSetup || other.isInitialSetup for item in other.items where !merged.items.contains(item) { merged.items.append(item) } diff --git a/Loop/Views/CurrentSetupView.swift b/Loop/Views/CurrentSetupView.swift index 079afa3634..15a0d5aa51 100644 --- a/Loop/Views/CurrentSetupView.swift +++ b/Loop/Views/CurrentSetupView.swift @@ -91,7 +91,7 @@ struct CurrentSetupView: View { VStack(alignment: .leading, spacing: 2) { Text(dateRange(change, end: history.end(of: change))) .foregroundColor(.primary) - Text(ListFormatter.localizedString(byJoining: change.items.map(\.title))) + Text(change.itemsDescription) .font(.footnote) .foregroundColor(.secondary) if let timeInRange = viewModel.timeInRange[change.date] { @@ -161,7 +161,14 @@ struct CurrentSetupView: View { } let changed = ListFormatter.localizedString(byJoining: change.items.map(\.title)) var sentences: [String] - if let end = viewModel.history?.end(of: change) { + if change.isInitialSetup { + if let end = viewModel.history?.end(of: change) { + sentences = [String(format: NSLocalizedString("Your initial setup from %1$@ ran for %2$@.", comment: "Setup summary first sentence for the initial setup, now replaced (1: start date, 2: duration)"), + change.date.formatted(date: .abbreviated, time: .omitted), duration(from: change.date, to: end))] + } else { + sentences = [String(format: NSLocalizedString("It's been %@ since your initial setup.", comment: "Setup summary first sentence when the initial setup is current (1: duration, e.g. 5 days)"), change.durationDescription)] + } + } else if let end = viewModel.history?.end(of: change) { sentences = [String(format: NSLocalizedString("The setup from %1$@ ran for %2$@, after a change to %3$@.", comment: "Setup summary first sentence for a past setup (1: start date, 2: duration, 3: list of what changed)"), change.date.formatted(date: .abbreviated, time: .omitted), duration(from: change.date, to: end), changed)] } else { @@ -225,6 +232,12 @@ extension SetupChange { } return duration } + + var itemsDescription: String { + isInitialSetup + ? NSLocalizedString("Initial setup", comment: "Setup change where the therapy settings were first entered") + : ListFormatter.localizedString(byJoining: items.map(\.title)) + } } extension SetupChange.Item { diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index cb52be5031..e02112dbdb 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -390,7 +390,7 @@ extension SettingsView { guard let item = setupChange.items.first else { return setupChange.durationDescription } - let changed = setupChange.items.count > 1 + let changed = setupChange.isInitialSetup ? setupChange.itemsDescription : setupChange.items.count > 1 ? String(format: NSLocalizedString("%@ and more", comment: "Several settings changed together (1: the first one)"), item.title) : item.title return String(format: NSLocalizedString("%1$@ · Last change: %2$@", comment: "Current setup settings descriptive text (1: time since the last change, 2: what changed)"), setupChange.durationDescription, changed) diff --git a/LoopTests/Models/SetupChangeTests.swift b/LoopTests/Models/SetupChangeTests.swift index d95c296493..b7487a246d 100644 --- a/LoopTests/Models/SetupChangeTests.swift +++ b/LoopTests/Models/SetupChangeTests.swift @@ -63,6 +63,12 @@ final class SetupChangeTests: XCTestCase { XCTAssertNil(history.end(of: history.changes[1])) } + func testInitialSetup() { + let onboarding = StoredSettings(date: start, dosingEnabled: false) + let history = history([onboarding, settings(at: 0.1), settings(at: 10, basal: 1.2)]) + XCTAssertEqual(history.changes.map(\.isInitialSetup), [true, false]) + } + func testChangesWithinAnHourAreGrouped() { let change = history([settings(at: 0), settings(at: 10, isf: 45), settings(at: 10.5, isf: 45, strategy: .tempBasalOnly)]).latest XCTAssertEqual(change?.date, start.addingTimeInterval(.hours(10.5))) From 7c9a548010b3b87885860f4e47824f00a7c427c4 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 27 Oct 2026 09:48:52 -0500 Subject: [PATCH 6/6] Skip settings records without therapy settings when finding setup changes --- Loop/Models/SetupChange.swift | 19 ++++++++++++++++--- LoopTests/Models/SetupChangeTests.swift | 7 +++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/Loop/Models/SetupChange.swift b/Loop/Models/SetupChange.swift index a724b099fe..8cce4340ce 100644 --- a/Loop/Models/SetupChange.swift +++ b/Loop/Models/SetupChange.swift @@ -41,11 +41,24 @@ struct SetupChange: Equatable { insulinType = settings.insulinType return settings } - return zip(sorted, sorted.dropFirst()).compactMap { previous, current in + // Records without therapy settings are saved before onboarding, and occasionally later + guard let firstEntered = sorted.firstIndex(where: hasTherapySettings) else { + return [] + } + let entered = sorted[firstEntered...].filter(hasTherapySettings) + var changes = zip(entered, entered.dropFirst()).compactMap { previous, current in let items = differences(from: previous, to: current) - let isInitialSetup = previous.basalRateSchedule == nil && previous.insulinSensitivitySchedule == nil && previous.carbRatioSchedule == nil - return items.isEmpty ? nil : SetupChange(date: current.date, isLowerBound: false, items: items, isInitialSetup: isInitialSetup) + return items.isEmpty ? nil : SetupChange(date: current.date, isLowerBound: false, items: items) + } + if firstEntered > 0 { + let initial = entered[0] + changes.insert(SetupChange(date: initial.date, isLowerBound: false, items: differences(from: sorted[firstEntered - 1], to: initial), isInitialSetup: true), at: 0) } + return changes + } + + private static func hasTherapySettings(_ settings: StoredSettings) -> Bool { + settings.basalRateSchedule != nil || settings.insulinSensitivitySchedule != nil || settings.carbRatioSchedule != nil } /// Experiments turned on or off between dosing decisions. Decisions from builds that didn't diff --git a/LoopTests/Models/SetupChangeTests.swift b/LoopTests/Models/SetupChangeTests.swift index b7487a246d..6abfc608ca 100644 --- a/LoopTests/Models/SetupChangeTests.swift +++ b/LoopTests/Models/SetupChangeTests.swift @@ -69,6 +69,13 @@ final class SetupChangeTests: XCTestCase { XCTAssertEqual(history.changes.map(\.isInitialSetup), [true, false]) } + func testRecordsWithoutTherapySettingsAreSkipped() { + let empty = StoredSettings(date: start.addingTimeInterval(.hours(5)), dosingEnabled: false) + let history = history([settings(at: 0), empty, settings(at: 6), settings(at: 10, basal: 1.2)]) + XCTAssertEqual(history.changes.map(\.date), [start.addingTimeInterval(.hours(10))]) + XCTAssertEqual(history.changes.map(\.isInitialSetup), [false]) + } + func testChangesWithinAnHourAreGrouped() { let change = history([settings(at: 0), settings(at: 10, isf: 45), settings(at: 10.5, isf: 45, strategy: .tempBasalOnly)]).latest XCTAssertEqual(change?.date, start.addingTimeInterval(.hours(10.5)))