From a8671a67df275d422b635e397184b0fc6a41cb94 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 26 Oct 2026 23:50:48 -0500 Subject: [PATCH 1/3] 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 e97219accb195c6a689349a0d19c69d92f7db01a Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 27 Oct 2026 08:29:25 -0500 Subject: [PATCH 2/3] 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 3/3] 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)))