diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 0ef947c208..aaaeaf19c3 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -534,6 +534,13 @@ 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 */; }; + 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 */; }; 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 +1381,13 @@ 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 = ""; }; + 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 = ""; }; 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 +1833,8 @@ A987CD4824A58A0100439ADC /* ZipArchive.swift */, C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */, C4C16D18C8806FF48331134C /* InsulinDeliveryHistory.swift */, + 12CD655083D4816F87B13463 /* SetupChange.swift */, + D5E7CDB17CCE70885EE93850 /* SetupComparison.swift */, C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */, C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */, 84213C882D94941000642E78 /* InsulinDeliveryLogEvent.swift */, @@ -2146,9 +2162,11 @@ B43B5C532EAFBF110096A6AE /* RecentGlucoseTableViewCell.xib */, C151634D2FA2C9C800FEECE8 /* RequiredVersionUpdateView.swift */, 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */, + CFD4A26A23202AF2EA506FCF /* CurrentSetupView.swift */, 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 */, @@ -2655,6 +2673,7 @@ C11BD0542523CFED00236B08 /* SimpleBolusViewModel.swift */, 1DB1CA4E24A56D7600B3B94C /* SettingsViewModel.swift */, FEEDF0000000000000000001 /* StatisticsViewModel.swift */, + 17C7C2227FAC0A9F7F570A01 /* CurrentSetupViewModel.swift */, 1D49795724E7289700948F05 /* ServicesViewModel.swift */, C174233B259BEB0F00399C9D /* ManualEntryDoseViewModel.swift */, 1DB619AB270BAD3D006C9D07 /* VersionUpdateViewModel.swift */, @@ -2757,6 +2776,8 @@ children = ( C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */, F69CE2EE3714858772FE7A26 /* InsulinDeliveryHistoryTests.swift */, + 03EC166B8AD05800AFCD474E /* SetupChangeTests.swift */, + BE36167100E59D7413C50F63 /* SetupComparisonTests.swift */, FEEDF0000000000000000005 /* GlucoseStatisticsTests.swift */, A9DFAFB224F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift */, A963B279252CEBAE0062AA12 /* SetBolusUserInfoTests.swift */, @@ -3704,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 */, @@ -3757,6 +3779,8 @@ 14C970842C5C2FB400E8A01B /* HowCarbEffectsWorksView.swift in Sources */, 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 */, @@ -3772,9 +3796,11 @@ 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 */, + 16097A41A1EF24BCF2F670DB /* Statistics/SetupComparisonCharts.swift in Sources */, FEEDB0000000000000000002 /* Statistics/StatisticsView.swift in Sources */, BAF043BF0BC70EC99AE79F71 /* HealthAccessView.swift in Sources */, A9B607B0247F000F00792BE4 /* UserNotifications+Loop.swift in Sources */, @@ -4027,6 +4053,8 @@ A9A63F8E246B271600588D5B /* NSTimeInterval.swift in Sources */, 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/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index fb162f500c..3e1110d169 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -609,6 +609,15 @@ class LoopAppManager: NSObject { guard let loopDataManager else { throw CancellationError() } return try await loopDataManager.insulinDeliveryHistory(days: days) } + 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 new file mode 100644 index 0000000000..8cce4340ce --- /dev/null +++ b/Loop/Models/SetupChange.swift @@ -0,0 +1,249 @@ +// +// SetupChange.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopAlgorithm + +/// 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) + 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] + /// 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) + + /// 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 + var settings = settings + settings.insulinType = settings.insulinType ?? insulinType + insulinType = settings.insulinType + return settings + } + // 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 + /// 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 + } + + /// 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) + merged.isInitialSetup = isInitialSetup || other.isInitialSetup + 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 let aType = a.insulinType, let bType = b.insulinType, aType != bType { + 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)) + } +} + +/// 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/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/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 78731a9c8c..87c5cb424d 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 setupHistory: (() async throws -> SetupHistory)? + @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..15a0d5aa51 --- /dev/null +++ b/Loop/Views/CurrentSetupView.swift @@ -0,0 +1,274 @@ +// +// CurrentSetupView.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI +import LoopAlgorithm + +struct CurrentSetupView: View { + @StateObject private var viewModel: CurrentSetupViewModel + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + + init(viewModel: @autoclosure @escaping () -> CurrentSetupViewModel) { + _viewModel = StateObject(wrappedValue: viewModel()) + } + + var body: some View { + List { + 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 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() + } + } + .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)) ?? "" + } +} + +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 + } + + 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 { + 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..770e703aa5 100644 --- a/Loop/Views/SettingsView+algorithmExperimentsSection.swift +++ b/Loop/Views/SettingsView+algorithmExperimentsSection.swift @@ -76,6 +76,10 @@ 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() diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 00755b87d1..e02112dbdb 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,44 @@ extension SettingsView { ) .accessibilityIdentifier("settingsViewStatistics") } + 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, + 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.setupHistory?().latest + } } } } + 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.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) + } + private func healthKitSharingStatus(for type: HKObjectType) -> HKAuthorizationStatus { viewModel.deviceManager?.healthKitSharingStatus(for: type) ?? .notDetermined } 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 new file mode 100644 index 0000000000..6abfc608ca --- /dev/null +++ b/LoopTests/Models/SetupChangeTests.swift @@ -0,0 +1,164 @@ +// +// 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, + insulinType: InsulinType? = .novolog, + timeZone: TimeZone = TimeZone(identifier: "America/Chicago")! + ) -> StoredSettings { + StoredSettings( + date: start.addingTimeInterval(.hours(hours)), + 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) + } + + 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 = history([settings(at: 5), settings(at: 0), settings(at: 10)]).latest + XCTAssertEqual(change, SetupChange(date: start, isLowerBound: true, items: [])) + } + + func testEmptyHistory() { + XCTAssertNil(history([]).latest) + } + + 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 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))) + XCTAssertEqual(change?.items, [.dosingStrategy, .therapySetting(.insulinSensitivity)]) + XCTAssertEqual(change?.previousDate, start) + } + + func testIgnoredChanges() { + let other = TimeZone(identifier: "Europe/Paris")! + 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 = 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 = history([settings(at: 0), settings(at: 1, isf: (mmol * 10).rounded() / 10, isfUnit: .millimolesPerLiter)]).latest + XCTAssertEqual(change?.isLowerBound, true) + } + + 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 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 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 testStartIgnoresDecisionsWithoutExperiments() { + let history = history([settings(at: 5)], [experiments(at: 1, nil), experiments(at: 3, [])]) + XCTAssertEqual(history.start, start.addingTimeInterval(.hours(3))) + } +} 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) + } +}