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 11ea38be7b..8cce4340ce 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) @@ -39,10 +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) 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 @@ -70,6 +86,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/Models/SetupComparison.swift b/Loop/Models/SetupComparison.swift new file mode 100644 index 0000000000..94c48872dd --- /dev/null +++ b/Loop/Models/SetupComparison.swift @@ -0,0 +1,70 @@ +// +// 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 setup is still in use, rather than replaced by a later change. + let isCurrent: Bool + + /// The current setup's comparison is still settling until it has run for about two weeks. + var isSettling: Bool { isCurrent && after.days * .days(1) < Self.suggestedDuration } + + /// Compares the setup `change` started, until `end` (the next change) or `now`, with the period before it. + init?(change: SetupChange, + end: Date? = nil, + samples: [StoredGlucoseSample], + insulinHistory: InsulinDeliveryHistory?, + now: Date, + calendar: Calendar = .current) { + let afterEnd = min(end ?? now, now) + guard !change.isLowerBound, afterEnd.timeIntervalSince(change.date) >= Self.minimumPeriod else { + return nil + } + isCurrent = end == 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, afterEnd) + + 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..ed10dfaa53 --- /dev/null +++ b/Loop/View Models/CurrentSetupViewModel.swift @@ -0,0 +1,80 @@ +// +// CurrentSetupViewModel.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +@MainActor +final class CurrentSetupViewModel: ObservableObject { + @Published private(set) var history: SetupHistory? + /// The change whose setup is being compared; the latest by default. + @Published private(set) var selected: SetupChange? + @Published private(set) var comparison: SetupComparison? + /// Time in range for each change's setup, by change date. + @Published private(set) var timeInRange: [Date: Double] = [:] + @Published private(set) var isLoading = true + + private let loadHistory: () async throws -> SetupHistory + private let glucoseStore: GlucoseStoreProtocol? + private let insulinDeliveryHistory: ((Int) async throws -> InsulinDeliveryHistory)? + private let cacheDuration: TimeInterval + private let now: () -> Date + + private var samples: [StoredGlucoseSample] = [] + private var insulin: InsulinDeliveryHistory? + + init(selected: SetupChange?, + loadHistory: @escaping () async throws -> SetupHistory, + glucoseStore: GlucoseStoreProtocol?, + insulinDeliveryHistory: ((Int) async throws -> InsulinDeliveryHistory)?, + cacheDuration: TimeInterval = Bundle.main.localCacheDuration, + now: @escaping () -> Date = { Date() }) { + self.selected = selected + self.loadHistory = loadHistory + self.glucoseStore = glucoseStore + self.insulinDeliveryHistory = insulinDeliveryHistory + self.cacheDuration = cacheDuration + self.now = now + } + + var isCurrentSelected: Bool { + selected != nil && selected == history?.latest + } + + func load() async { + defer { isLoading = false } + guard let history = try? await loadHistory() else { + return + } + self.history = history + selected = history.latest + let end = now() + if let glucoseStore { + samples = (try? await glucoseStore.getGlucoseSamples(start: end - cacheDuration, end: end))? + .filter { !$0.isDisplayOnly && !$0.wasUserEntered } ?? [] + } + insulin = try? await insulinDeliveryHistory?(Int((cacheDuration / .days(1)).rounded())) + timeInRange = Dictionary(uniqueKeysWithValues: history.changes.compactMap { change in + let stats = GlucoseStatistics(samples: samples, start: change.date, end: history.end(of: change) ?? end) + return stats.timeInRange.isEmpty ? nil : (change.date, stats.timeInRange[.target, default: 0]) + }) + compare() + } + + func select(_ change: SetupChange) { + selected = change + compare() + } + + private func compare() { + guard let selected, let history else { + comparison = nil + return + } + comparison = SetupComparison(change: selected, end: history.end(of: selected), samples: samples, insulinHistory: insulin, now: now()) + } +} diff --git a/Loop/Views/CurrentSetupView.swift b/Loop/Views/CurrentSetupView.swift index 08fd7742c9..15a0d5aa51 100644 --- a/Loop/Views/CurrentSetupView.swift +++ b/Loop/Views/CurrentSetupView.swift @@ -11,29 +11,42 @@ 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()) + } 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 { + if let selected = viewModel.selected { + Section { + Text(summary(selected)) + 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: comparison.isCurrent + ? NSLocalizedString("Since Change", comment: "Title of the time in range bar for the period since the setup change") + : NSLocalizedString("This Setup", comment: "Title of the time in range bar for a past setup"), + timeInRange: comparison.after.glucose.timeInRange) + } else if viewModel.isLoading { + HStack { Spacer(); ProgressView(); Spacer() } + } + } + if let comparison = viewModel.comparison { + resultsSection(comparison) + } + } else if !viewModel.isLoading { + Section { 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) - } - } + if let history = viewModel.history, !history.changes.isEmpty { + historySection(history) } 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() @@ -41,6 +54,169 @@ 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 with this setup, 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 with this setup, with its 25–75% band.", 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 historySection(_ history: SetupHistory) -> some View { + Section(header: SectionHeader(label: NSLocalizedString("Setup History", comment: "Header of the list of past setup changes")), + footer: Text(NSLocalizedString("Tap a change to compare that setup with the period before it.", comment: "Footer of the setup history list"))) { + ForEach(history.changes.reversed(), id: \.date) { change in + Button { + viewModel.select(change) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(dateRange(change, end: history.end(of: change))) + .foregroundColor(.primary) + Text(change.itemsDescription) + .font(.footnote) + .foregroundColor(.secondary) + if let timeInRange = viewModel.timeInRange[change.date] { + Text(String(format: NSLocalizedString("%@ in range", comment: "Time in range for a period (1: percent)"), percent(timeInRange))) + .font(.footnote) + .foregroundColor(.secondary) + .monospacedDigit() + } + } + Spacer() + if change == viewModel.selected { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + } + } + } + } + + private func dateRange(_ change: SetupChange, end: Date?) -> String { + guard let end else { + return String(format: NSLocalizedString("%@ – Now", comment: "Date range of the current setup (1: start date)"), change.date.formatted(date: .abbreviated, time: .shortened)) + } + return String(format: NSLocalizedString("%1$@ · %2$@", comment: "Start date and length of a past setup (1: start date, 2: duration)"), change.date.formatted(date: .abbreviated, time: .shortened), duration(from: change.date, to: end)) + } + + private func duration(from start: Date, to end: Date) -> String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.day, .hour, .minute] + formatter.maximumUnitCount = 1 + formatter.unitsStyle = .full + return formatter.string(from: max(60, end.timeIntervalSince(start))) ?? "" + } + + 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)) + } + let changed = ListFormatter.localizedString(byJoining: change.items.map(\.title)) + var sentences: [String] + 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 { + sentences = [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, changed)] + } + guard let comparison = viewModel.comparison else { + if viewModel.history?.end(of: change) != nil { + sentences.append(NSLocalizedString("That's too short to compare.", comment: "Setup summary for a past setup that ran less than a day")) + } 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 this setup's time.", 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)) ?? "" } } @@ -56,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 09afc2870b..e02112dbdb 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( + selected: setupChange, + loadHistory: { [viewModel] in try await viewModel.setupHistory?() ?? SetupHistory(settings: [], experiments: []) }, + glucoseStore: glucoseStore, + insulinDeliveryHistory: viewModel.insulinDeliveryHistory + ))) { LargeButton( action: {}, includeArrow: false, @@ -385,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/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 d95c296493..6abfc608ca 100644 --- a/LoopTests/Models/SetupChangeTests.swift +++ b/LoopTests/Models/SetupChangeTests.swift @@ -63,6 +63,19 @@ 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 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))) diff --git a/LoopTests/Models/SetupComparisonTests.swift b/LoopTests/Models/SetupComparisonTests.swift new file mode 100644 index 0000000000..b023f364fe --- /dev/null +++ b/LoopTests/Models/SetupComparisonTests.swift @@ -0,0 +1,119 @@ +// +// 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 testPastSetupEndsAtTheNextChange() throws { + let now = changeDate.addingTimeInterval(.days(30)) + let glucose = samples(from: changeDate - .days(20), to: now) { _ in 120 } + let comparison = try XCTUnwrap(SetupComparison(change: change(), end: changeDate + .days(6), samples: glucose, insulinHistory: nil, now: now)) + + XCTAssertEqual(comparison.after.end, changeDate + .days(6)) + XCTAssertFalse(comparison.isCurrent) + XCTAssertFalse(comparison.isSettling) + XCTAssertNil(SetupComparison(change: change(), end: changeDate + .hours(10), samples: glucose, insulinHistory: nil, now: now)) + } + + 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) + } +}