diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 0095528757..05301b2c0b 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -486,6 +486,7 @@ C16B984026B4898800256B05 /* DoseEnactorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16B983F26B4898800256B05 /* DoseEnactorTests.swift */; }; 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 */; }; 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 */; }; @@ -651,8 +652,10 @@ FEEDB0000000000000000000 /* GlucoseStatistics.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDF0000000000000000000 /* GlucoseStatistics.swift */; }; FEEDB0000000000000000001 /* StatisticsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDF0000000000000000001 /* StatisticsViewModel.swift */; }; FEEDB0000000000000000002 /* Statistics/StatisticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDF0000000000000000002 /* Statistics/StatisticsView.swift */; }; + A3C9F155FD92DC188BD9A21F /* Statistics/DailyInsulinChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 747DA22B61531CCAAC79E86D /* Statistics/DailyInsulinChartView.swift */; }; FEEDB0000000000000000003 /* Statistics/AGPChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDF0000000000000000003 /* Statistics/AGPChartView.swift */; }; FEEDB0000000000000000004 /* Statistics/TimeInRangeBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDF0000000000000000004 /* Statistics/TimeInRangeBar.swift */; }; + 30E4A68B8C914C5E4B245555 /* InsulinDeliveryHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F69CE2EE3714858772FE7A26 /* InsulinDeliveryHistoryTests.swift */; }; FEEDB0000000000000000005 /* GlucoseStatisticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDF0000000000000000005 /* GlucoseStatisticsTests.swift */; }; /* End PBXBuildFile section */ @@ -1319,6 +1322,7 @@ C16B983F26B4898800256B05 /* DoseEnactorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoseEnactorTests.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -1457,8 +1461,10 @@ FEEDF0000000000000000000 /* GlucoseStatistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStatistics.swift; sourceTree = ""; }; FEEDF0000000000000000001 /* StatisticsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatisticsViewModel.swift; sourceTree = ""; }; FEEDF0000000000000000002 /* Statistics/StatisticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Statistics/StatisticsView.swift; sourceTree = ""; }; + 747DA22B61531CCAAC79E86D /* Statistics/DailyInsulinChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Statistics/DailyInsulinChartView.swift; sourceTree = ""; }; FEEDF0000000000000000003 /* Statistics/AGPChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Statistics/AGPChartView.swift; sourceTree = ""; }; FEEDF0000000000000000004 /* Statistics/TimeInRangeBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Statistics/TimeInRangeBar.swift; sourceTree = ""; }; + F69CE2EE3714858772FE7A26 /* InsulinDeliveryHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryHistoryTests.swift; sourceTree = ""; }; FEEDF0000000000000000005 /* GlucoseStatisticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStatisticsTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -1723,6 +1729,7 @@ 4328E0311CFC068900E199AA /* WatchContext+LoopKit.swift */, A987CD4824A58A0100439ADC /* ZipArchive.swift */, C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */, + C4C16D18C8806FF48331134C /* InsulinDeliveryHistory.swift */, C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */, C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */, 84213C882D94941000642E78 /* InsulinDeliveryLogEvent.swift */, @@ -2051,6 +2058,7 @@ C151634D2FA2C9C800FEECE8 /* RequiredVersionUpdateView.swift */, 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */, FEEDF0000000000000000004 /* Statistics/TimeInRangeBar.swift */, + 747DA22B61531CCAAC79E86D /* Statistics/DailyInsulinChartView.swift */, FEEDF0000000000000000003 /* Statistics/AGPChartView.swift */, FEEDF0000000000000000002 /* Statistics/StatisticsView.swift */, E4854073AAE5C2CB97167F63 /* HealthAccessView.swift */, @@ -2614,6 +2622,7 @@ isa = PBXGroup; children = ( C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */, + F69CE2EE3714858772FE7A26 /* InsulinDeliveryHistoryTests.swift */, FEEDF0000000000000000005 /* GlucoseStatisticsTests.swift */, A9DFAFB224F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift */, A963B279252CEBAE0062AA12 /* SetBolusUserInfoTests.swift */, @@ -3574,6 +3583,7 @@ B43CF07E29434EC4008A520B /* HowMuteAlertWorkView.swift in Sources */, 14C970842C5C2FB400E8A01B /* HowCarbEffectsWorksView.swift in Sources */, 1D6B1B6726866D89009AC446 /* AlertPermissionsChecker.swift in Sources */, + 52ECDA0F09B4C8F23A4B450E /* InsulinDeliveryHistory.swift in Sources */, C16F511B2B89363A00EFD7A1 /* SimpleInsulinDose.swift in Sources */, 4F08DE8F1E7BB871006741EA /* CollectionType+Loop.swift in Sources */, A9F703772489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift in Sources */, @@ -3590,6 +3600,7 @@ A9DCF32A25B0FABF00C89088 /* LoopUIColorPalette+Default.swift in Sources */, 1DDE273E24AEA4B000796622 /* SettingsView.swift in Sources */, FEEDB0000000000000000004 /* Statistics/TimeInRangeBar.swift in Sources */, + A3C9F155FD92DC188BD9A21F /* Statistics/DailyInsulinChartView.swift in Sources */, FEEDB0000000000000000003 /* Statistics/AGPChartView.swift in Sources */, FEEDB0000000000000000002 /* Statistics/StatisticsView.swift in Sources */, BAF043BF0BC70EC99AE79F71 /* HealthAccessView.swift in Sources */, @@ -3840,6 +3851,7 @@ C16B984026B4898800256B05 /* DoseEnactorTests.swift in Sources */, A9A63F8E246B271600588D5B /* NSTimeInterval.swift in Sources */, C152B9F72C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift in Sources */, + 30E4A68B8C914C5E4B245555 /* InsulinDeliveryHistoryTests.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 86f5ada6de..d5e4a31766 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -604,7 +604,11 @@ class LoopAppManager: NSObject { ) viewModel.favoriteFoodInsightsDelegate = loopDataManager - + viewModel.insulinDeliveryHistory = { [weak loopDataManager] days in + guard let loopDataManager else { throw CancellationError() } + return try await loopDataManager.insulinDeliveryHistory(days: days) + } + return viewModel }() diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 2ef40ed850..472f4468b4 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -852,13 +852,35 @@ final class LoopDataManager: ObservableObject { let now = data.predictionStart let midnight = Calendar.current.startOfDay(for: now) - - let annotatedDoses = data.doses.annotated(with: data.basal, fillBasalGaps: true) - let trimmed = annotatedDoses.map { $0.trimmed(from: midnight, to: now)} + let history = InsulinDeliveryHistory(doses: data.doses, scheduledBasal: data.basal, coverageStart: midnight, end: now) return InsulinValue( startDate: midnight, - value: trimmed.reduce(0.0) { $0 + $1.volume } + value: history.totals(from: midnight, to: now).total + ) + } + + /// Delivery history from midnight `days` days ago until now. + func insulinDeliveryHistory(days: Int) async throws -> InsulinDeliveryHistory { + let calendar = Calendar.current + let end = now + let start = calendar.date(byAdding: .day, value: -days, to: calendar.startOfDay(for: end)) ?? end.addingTimeInterval(.days(-Double(days))) + + let doses = try await doseStore.getNormalizedDoseEntries(start: start, end: nil) + let earliestDose = doses.map(\.startDate).min() + let basalStart = min(start, earliestDose ?? start) + + var basal = try await settingsProvider.getBasalHistory(startDate: basalStart, endDate: end) + let basalHistoryStart = basal.first?.startDate ?? end + if basalHistoryStart > basalStart, let schedule = settingsProvider.settings.basalRateSchedule { + basal = schedule.truncatingBetween(start: basalStart, end: basalHistoryStart) + basal + } + + return InsulinDeliveryHistory( + doses: doses.map { $0.simpleDose(with: insulinModel(for: $0.insulinType)) }, + scheduledBasal: basal, + coverageStart: max(start, earliestDose ?? end), + end: end ) } diff --git a/Loop/Models/InsulinDeliveryHistory.swift b/Loop/Models/InsulinDeliveryHistory.swift new file mode 100644 index 0000000000..72603ebd81 --- /dev/null +++ b/Loop/Models/InsulinDeliveryHistory.swift @@ -0,0 +1,142 @@ +// +// InsulinDeliveryHistory.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopAlgorithm + +struct InsulinDeliveryTotals: Equatable { + var basal: Double = 0 + var automaticBolus: Double = 0 + var manualBolus: Double = 0 + + var bolus: Double { automaticBolus + manualBolus } + var total: Double { basal + bolus } +} + +struct DailyInsulinDelivery: Equatable, Identifiable { + let start: Date + let end: Date + let totals: InsulinDeliveryTotals + let isComplete: Bool + + var id: Date { start } +} + +struct TypicalInsulinDelivery: Equatable { + let median: Double + let dayCount: Int +} + +/// Insulin delivered over a span of time, from normalized doses. Time not covered by a basal +/// record is counted at the scheduled basal rate, since that is what the pump delivers on its own. +struct InsulinDeliveryHistory { + let doses: [SimpleInsulinDose] + let scheduledBasal: [AbsoluteScheduleValue] + /// Earliest time the dose records can be trusted to cover. + let coverageStart: Date + let end: Date + + init(doses: [SimpleInsulinDose], scheduledBasal: [AbsoluteScheduleValue], coverageStart: Date, end: Date) { + self.doses = doses.sorted { $0.startDate < $1.startDate } + self.scheduledBasal = scheduledBasal.sorted { $0.startDate < $1.startDate } + self.coverageStart = coverageStart + self.end = end + } + + func totals(from start: Date, to end: Date) -> InsulinDeliveryTotals { + var totals = InsulinDeliveryTotals() + guard start < end else { return totals } + + var basalCursor = start + for dose in doses { + switch dose.deliveryType { + case .bolus: + let units = dose.volume(from: start, to: end) + if dose.automatic { + totals.automaticBolus += units + } else { + totals.manualBolus += units + } + case .basal: + let doseStart = max(dose.startDate, basalCursor) + let doseEnd = min(dose.endDate, end) + guard doseStart < doseEnd else { continue } + totals.basal += scheduledUnits(from: basalCursor, to: doseStart) + totals.basal += dose.volume(from: doseStart, to: doseEnd) + basalCursor = doseEnd + } + } + totals.basal += scheduledUnits(from: basalCursor, to: end) + return totals + } + + /// One entry per calendar day that the records fully cover, oldest first. The current day + /// is included as an incomplete entry ending at `end`. + func dailyTotals(calendar: Calendar = .current) -> [DailyInsulinDelivery] { + var days: [DailyInsulinDelivery] = [] + var dayStart = calendar.startOfDay(for: coverageStart) + if dayStart < coverageStart, let next = calendar.date(byAdding: .day, value: 1, to: dayStart) { + dayStart = next + } + while dayStart < end, let next = calendar.date(byAdding: .day, value: 1, to: dayStart) { + let dayEnd = min(next, end) + days.append(DailyInsulinDelivery( + start: dayStart, + end: dayEnd, + totals: totals(from: dayStart, to: dayEnd), + isComplete: next <= end + )) + dayStart = next + } + return days + } + + /// The median amount delivered between midnight and the same clock time as `date`, over the + /// previous `days` days that the records cover. + func typicalDelivery(by date: Date, days: Int = 14, minimumDays: Int = 3, calendar: Calendar = .current) -> TypicalInsulinDelivery? { + let amounts: [Double] = (1...max(1, days)).compactMap { offset in + guard let sameTime = calendar.date(byAdding: .day, value: -offset, to: date) else { return nil } + let dayStart = calendar.startOfDay(for: sameTime) + guard dayStart >= coverageStart else { return nil } + return totals(from: dayStart, to: sameTime).total + } + guard amounts.count >= minimumDays else { return nil } + return TypicalInsulinDelivery(median: amounts.median, dayCount: amounts.count) + } + + private func scheduledUnits(from start: Date, to end: Date) -> Double { + guard start < end else { return 0 } + return scheduledBasal.reduce(0) { sum, entry in + let overlapStart = max(start, entry.startDate) + let overlapEnd = min(end, entry.endDate) + guard overlapStart < overlapEnd else { return sum } + return sum + entry.value * overlapEnd.timeIntervalSince(overlapStart).hours + } + } +} + +extension SimpleInsulinDose { + /// Units delivered within the interval. A dose with no duration counts only if it falls inside. + fileprivate func volume(from start: Date, to end: Date) -> Double { + let duration = endDate.timeIntervalSince(startDate) + guard duration > 0 else { + return startDate >= start && startDate < end ? volume : 0 + } + let overlap = min(end, endDate).timeIntervalSince(max(start, startDate)) + return overlap > 0 ? volume * overlap / duration : 0 + } +} + +extension Array where Element == Double { + fileprivate var median: Double { + let sorted = self.sorted() + guard !sorted.isEmpty else { return 0 } + let middle = sorted.count / 2 + return sorted.count.isMultiple(of: 2) ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] + } +} diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index ac6a2cdbd9..78731a9c8c 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -119,6 +119,8 @@ class SettingsViewModel { @ObservationIgnored weak var favoriteFoodInsightsDelegate: FavoriteFoodInsightsViewModelDelegate? + @ObservationIgnored var insulinDeliveryHistory: ((Int) async throws -> InsulinDeliveryHistory)? + @MainActor var showDeleteTestData: Bool { availableSupports.contains(where: { $0.showsDeleteTestDataUI }) diff --git a/Loop/View Models/StatisticsViewModel.swift b/Loop/View Models/StatisticsViewModel.swift index d3bb0358f1..575ffb49b4 100644 --- a/Loop/View Models/StatisticsViewModel.swift +++ b/Loop/View Models/StatisticsViewModel.swift @@ -35,24 +35,50 @@ final class StatisticsViewModel: ObservableObject { /// Days of glucose actually available (bounded by data accumulated and the cache). @Published private(set) var availableDays: Double = 0 + /// Complete days of insulin delivery within the selected range, oldest first. + @Published private(set) var insulinDays: [DailyInsulinDelivery] = [] + @Published private(set) var insulinToday: DailyInsulinDelivery? + @Published private(set) var typicalInsulinByNow: TypicalInsulinDelivery? + private var allSamples: [StoredGlucoseSample] = [] + private var allInsulinDays: [DailyInsulinDelivery] = [] private let glucoseStore: GlucoseStoreProtocol + private let insulinDeliveryHistory: ((Int) async throws -> InsulinDeliveryHistory)? private let calendar: Calendar private let now: () -> Date /// Configured local cache duration (how far back glucose is retained on device). let cacheDuration: TimeInterval + let scheduledBasalTotal: Double? init(glucoseStore: GlucoseStoreProtocol, + insulinDeliveryHistory: ((Int) async throws -> InsulinDeliveryHistory)? = nil, + scheduledBasalTotal: Double? = nil, cacheDuration: TimeInterval = Bundle.main.localCacheDuration, calendar: Calendar = .current, now: @escaping () -> Date = { Date() }) { self.glucoseStore = glucoseStore + self.insulinDeliveryHistory = insulinDeliveryHistory + self.scheduledBasalTotal = scheduledBasalTotal self.cacheDuration = cacheDuration self.calendar = calendar self.now = now } + var showsInsulin: Bool { insulinDeliveryHistory != nil } + + /// Mean of the complete days in the selected range. + var averageInsulinDay: InsulinDeliveryTotals? { + guard !insulinDays.isEmpty else { return nil } + let count = Double(insulinDays.count) + let sum = insulinDays.reduce(into: InsulinDeliveryTotals()) { sum, day in + sum.basal += day.totals.basal + sum.automaticBolus += day.totals.automaticBolus + sum.manualBolus += day.totals.manualBolus + } + return InsulinDeliveryTotals(basal: sum.basal / count, automaticBolus: sum.automaticBolus / count, manualBolus: sum.manualBolus / count) + } + var cacheDurationDays: Int { Int((cacheDuration / 86400).rounded()) } /// Info notices for the selected range: shown when the window is incomplete, @@ -86,6 +112,13 @@ final class StatisticsViewModel: ObservableObject { allSamples = [] } availableDays = allSamples.first.map { end.timeIntervalSince($0.startDate) / 86400 } ?? 0 + + if let insulinDeliveryHistory, let history = try? await insulinDeliveryHistory(cacheDurationDays) { + let days = history.dailyTotals(calendar: calendar) + allInsulinDays = days.filter(\.isComplete) + insulinToday = days.last(where: { !$0.isComplete }) + typicalInsulinByNow = history.typicalDelivery(by: history.end, calendar: calendar) + } recompute() } @@ -96,5 +129,7 @@ final class StatisticsViewModel: ObservableObject { let start = calendar.date(byAdding: .day, value: -selectedRange.days, to: end) ?? end.addingTimeInterval(-Double(selectedRange.days) * 24 * 60 * 60) statistics = GlucoseStatistics(samples: allSamples, start: start, end: end, calendar: calendar) + let firstDay = calendar.date(byAdding: .day, value: -selectedRange.days, to: calendar.startOfDay(for: end)) ?? start + insulinDays = allInsulinDays.filter { $0.start >= firstDay } } } diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift index d8b1fd04e7..eeac82bee4 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift @@ -25,17 +25,23 @@ struct InsulinDeliveryLog: View { self.onEnterManualDose = onEnterManualDose } - private func totalInsulinDeliveredLabel(from total: LoopQuantity) -> some View { + private func totalInsulinDeliveredLabel(from total: LoopQuantity, typical: DatedQuantity?) -> some View { LabeledContent { Text(viewModel.totalDeliveredFormatter.string(from: total) ?? "Unknown") .foregroundStyle(.secondary) } label: { VStack(alignment: .leading, spacing: 0) { Text("Total Insulin Delivery") - + Text("since \(Calendar.current.startOfDay(for: Date()).formatted(date: .omitted, time: .shortened))") .font(.footnote) .foregroundStyle(.secondary) + + if let typical, let amount = viewModel.totalDeliveredFormatter.string(from: typical.quantity) { + Text("usually \(amount) by \(typical.date.formatted(date: .omitted, time: .shortened))", comment: "Usual insulin delivered by this time of day (1: units, 2: time of day)") + .font(.footnote) + .foregroundStyle(.secondary) + } } } } @@ -148,7 +154,7 @@ struct InsulinDeliveryLog: View { } Section { - totalInsulinDeliveredLabel(from: data.totalInsulinDelivered) + totalInsulinDeliveredLabel(from: data.totalInsulinDelivered, typical: data.typicalInsulinDelivered) } } diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift index a0f563103d..2a225f8de8 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift @@ -38,7 +38,7 @@ class InsulinDeliveryLogViewModel { } struct DisplayData: Hashable { - let insulinDeliveryState: InsulinDeliveryOverview.State, insulinDeliveryStateUpdatedDate: Date, currentBasalRate: DatedQuantity, lastAutoBolus: DatedQuantity?, totalInsulinDelivered: LoopQuantity, events: [InsulinDeliveryLogEvent] + let insulinDeliveryState: InsulinDeliveryOverview.State, insulinDeliveryStateUpdatedDate: Date, currentBasalRate: DatedQuantity, lastAutoBolus: DatedQuantity?, totalInsulinDelivered: LoopQuantity, typicalInsulinDelivered: DatedQuantity?, events: [InsulinDeliveryLogEvent] } enum State: Hashable { @@ -176,7 +176,7 @@ class InsulinDeliveryLogViewModel { let startDate = fetchedDate.addingTimeInterval(.days(-1)) let statusState = fetchStatusState() - let totalInsulinDelivered = await fetchTotalInsulinDeliveredToday() + let (totalInsulinDelivered, typicalInsulinDelivered) = await fetchInsulinDeliveredToday() let doses = await fetchDoses(since: startDate) let lastAutoBolus = fetchLastAutoBolus(doses: doses) let decisions = await fetchDosingDecisions(doses.compactMap(\.decisionId)) @@ -195,6 +195,7 @@ class InsulinDeliveryLogViewModel { currentBasalRate: fetchCurrentBasal() ?? DatedQuantity(date: TestingDate.currentTestingDate(), quantity: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: 0)), lastAutoBolus: lastAutoBolus, totalInsulinDelivered: totalInsulinDelivered, + typicalInsulinDelivered: typicalInsulinDelivered, events: events ) ) @@ -266,8 +267,16 @@ class InsulinDeliveryLogViewModel { (try? await loopDataManager.dosingDecisionStore.findDosingDecisionsByIds(ids)) ?? [] } - private func fetchTotalInsulinDeliveredToday() async -> LoopQuantity { - await LoopQuantity(unit: .internationalUnit, doubleValue: loopDataManager.totalDeliveredToday()?.value ?? 0) + /// Today's total, and the usual amount by this time of day when there's enough history. + private func fetchInsulinDeliveredToday() async -> (LoopQuantity, DatedQuantity?) { + guard let history = try? await loopDataManager.insulinDeliveryHistory(days: 14) else { + return (await LoopQuantity(unit: .internationalUnit, doubleValue: loopDataManager.totalDeliveredToday()?.value ?? 0), nil) + } + let today = history.totals(from: Calendar.current.startOfDay(for: history.end), to: history.end).total + let typical = history.typicalDelivery(by: history.end).map { + DatedQuantity(date: history.end, quantity: LoopQuantity(unit: .internationalUnit, doubleValue: $0.median)) + } + return (LoopQuantity(unit: .internationalUnit, doubleValue: today), typical) } private func handleBasalEvent(dose: DoseEntry, decision: LightDosingDecision?, events: inout [InsulinDeliveryLogEvent]) { diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 6f70ef1c91..1faefbc5e1 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -328,7 +328,11 @@ extension SettingsView { private var statisticsSection: some View { if let glucoseStore = viewModel.deviceManager?.glucoseStore { Section { - NavigationLink(destination: StatisticsView(glucoseStore: glucoseStore)) { + NavigationLink(destination: StatisticsView( + glucoseStore: glucoseStore, + insulinDeliveryHistory: viewModel.insulinDeliveryHistory, + scheduledBasalTotal: viewModel.therapySettings().basalRateSchedule?.total() + )) { LargeButton( action: {}, includeArrow: false, @@ -337,7 +341,7 @@ extension SettingsView { .aspectRatio(contentMode: .fit) .frame(width: 30), label: NSLocalizedString("Statistics", comment: "Statistics settings button text"), - descriptiveText: NSLocalizedString("Glucose overview and ambulatory glucose profile", comment: "Statistics settings descriptive text") + descriptiveText: NSLocalizedString("Glucose overview, ambulatory glucose profile and daily insulin", comment: "Statistics settings descriptive text") ) .accessibilityIdentifier("settingsViewStatistics") } diff --git a/Loop/Views/Statistics/DailyInsulinChartView.swift b/Loop/Views/Statistics/DailyInsulinChartView.swift new file mode 100644 index 0000000000..e372747da3 --- /dev/null +++ b/Loop/Views/Statistics/DailyInsulinChartView.swift @@ -0,0 +1,83 @@ +// +// DailyInsulinChartView.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import Charts +import LoopKitUI + +/// Insulin delivered per calendar day, stacked by basal, automatic bolus and manual bolus, +/// with the average as a dashed line. +struct DailyInsulinChartView: View { + let days: [DailyInsulinDelivery] + let average: Double? + + @Environment(\.insulinTintColor) private var insulinTintColor + + private enum Part: CaseIterable { + case basal, automaticBolus, manualBolus + + var name: String { + switch self { + case .basal: + return NSLocalizedString("Basal", comment: "Daily insulin chart legend for basal delivery") + case .automaticBolus: + return NSLocalizedString("Automatic Bolus", comment: "Daily insulin chart legend for automatic boluses") + case .manualBolus: + return NSLocalizedString("Manual Bolus", comment: "Daily insulin chart legend for manual boluses") + } + } + + func units(_ totals: InsulinDeliveryTotals) -> Double { + switch self { + case .basal: return totals.basal + case .automaticBolus: return totals.automaticBolus + case .manualBolus: return totals.manualBolus + } + } + } + + private var labelStride: Int { + switch days.count { + case ...8: return 1 + case ...15: return 2 + case ...31: return 7 + default: return 14 + } + } + + var body: some View { + Chart { + ForEach(days) { day in + ForEach(Part.allCases, id: \.self) { part in + BarMark( + x: .value("Day", day.start, unit: .day), + y: .value("Units", part.units(day.totals)) + ) + .foregroundStyle(by: .value("Delivery", part.name)) + } + } + if let average { + RuleMark(y: .value("Average", average)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) + .foregroundStyle(Color.primary.opacity(0.6)) + } + } + .chartForegroundStyleScale([ + Part.basal.name: insulinTintColor.opacity(0.35), + Part.automaticBolus.name: insulinTintColor.opacity(0.65), + Part.manualBolus.name: insulinTintColor, + ]) + .chartXAxis { + AxisMarks(values: .stride(by: .day, count: labelStride)) { + AxisGridLine() + AxisValueLabel(format: .dateTime.month(.defaultDigits).day(), centered: true) + } + } + .chartLegend(position: .bottom, alignment: .leading) + .accessibilityLabel(Text(NSLocalizedString("Insulin delivered per day", comment: "Accessibility label for the daily insulin chart"))) + } +} diff --git a/Loop/Views/Statistics/StatisticsView.swift b/Loop/Views/Statistics/StatisticsView.swift index 8c29d8ab6a..23b41652fc 100644 --- a/Loop/Views/Statistics/StatisticsView.swift +++ b/Loop/Views/Statistics/StatisticsView.swift @@ -18,8 +18,8 @@ struct StatisticsView: View { @StateObject private var viewModel: StatisticsViewModel @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference - init(glucoseStore: GlucoseStoreProtocol) { - _viewModel = StateObject(wrappedValue: StatisticsViewModel(glucoseStore: glucoseStore)) + init(glucoseStore: GlucoseStoreProtocol, insulinDeliveryHistory: ((Int) async throws -> InsulinDeliveryHistory)? = nil, scheduledBasalTotal: Double? = nil) { + _viewModel = StateObject(wrappedValue: StatisticsViewModel(glucoseStore: glucoseStore, insulinDeliveryHistory: insulinDeliveryHistory, scheduledBasalTotal: scheduledBasalTotal)) } var body: some View { @@ -62,6 +62,10 @@ struct StatisticsView: View { .foregroundColor(.secondary) } } + + if viewModel.showsInsulin { + insulinSection + } } .navigationTitle(Text(NSLocalizedString("Statistics", comment: "Statistics screen title"))) .navigationBarTitleDisplayMode(.inline) @@ -122,6 +126,58 @@ struct StatisticsView: View { } } + 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 percent(_ part: Double, of total: Double) -> String { + total > 0 ? (part / total).formatted(.percent.precision(.fractionLength(0))) : "–" + } + + private var todayCaption: String { + guard let typical = viewModel.typicalInsulinByNow, let today = viewModel.insulinToday else { + return NSLocalizedString("Since midnight", comment: "Caption for insulin delivered today when there is no usual amount to compare") + } + return String(format: NSLocalizedString("Usually %1$@ by %2$@", comment: "Caption comparing today's insulin with the usual amount by this time (1: units, 2: time of day)"), + units(typical.median), today.end.formatted(date: .omitted, time: .shortened)) + } + + private var insulinSection: some View { + let average = viewModel.averageInsulinDay + return Section(header: Text(NSLocalizedString("Daily Insulin", comment: "Daily insulin section header")), + footer: Text(NSLocalizedString("Each bar is one calendar day; the dashed line is the average. Today isn't included until the day is over. Usual amounts are the median of the last 14 days at the same time of day.", comment: "Daily insulin section explanation"))) { + LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) { + metric(NSLocalizedString("Average", comment: "Average daily insulin metric title"), + value: average.map { units($0.total) }, + goal: String(format: NSLocalizedString("Per day, over %d days", comment: "Caption for average daily insulin (1: number of days)"), viewModel.insulinDays.count)) + metric(NSLocalizedString("Today", comment: "Insulin delivered today metric title"), + value: viewModel.insulinToday.map { units($0.totals.total) }, + goal: todayCaption) + metric(NSLocalizedString("Basal", comment: "Average daily basal metric title"), + value: average.map { units($0.basal) }, + goal: average.map { average in + if let scheduled = viewModel.scheduledBasalTotal { + return String(format: NSLocalizedString("%1$@ · scheduled %2$@", comment: "Caption for average daily basal (1: percent of total, 2: scheduled basal units)"), percent(average.basal, of: average.total), units(scheduled)) + } + return percent(average.basal, of: average.total) + } ?? "–") + metric(NSLocalizedString("Bolus", comment: "Average daily bolus metric title"), + value: average.map { units($0.bolus) }, + goal: average.map { average in + String(format: NSLocalizedString("%1$@ · %2$@ automatic", comment: "Caption for average daily bolus (1: percent of total, 2: automatic bolus units)"), percent(average.bolus, of: average.total), units(average.automaticBolus)) + } ?? "–") + } + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + .listRowBackground(Color.clear) + + if !viewModel.insulinDays.isEmpty { + DailyInsulinChartView(days: viewModel.insulinDays, average: average?.total) + .frame(height: 220) + .padding(.vertical, 8) + } + } + } + @ViewBuilder private func agpSection(_ stats: GlucoseStatistics) -> some View { Section(header: Text(NSLocalizedString("Daily Glucose Pattern", comment: "Daily glucose pattern (AGP) section header")), diff --git a/LoopTests/Models/InsulinDeliveryHistoryTests.swift b/LoopTests/Models/InsulinDeliveryHistoryTests.swift new file mode 100644 index 0000000000..150161c6db --- /dev/null +++ b/LoopTests/Models/InsulinDeliveryHistoryTests.swift @@ -0,0 +1,155 @@ +// +// InsulinDeliveryHistoryTests.swift +// LoopTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +import LoopKit +import LoopAlgorithm +@testable import Loop + +final class InsulinDeliveryHistoryTests: XCTestCase { + private var calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Chicago")! + return calendar + }() + + private func date(_ day: Int, _ hour: Double) -> Date { + let midnight = calendar.date(from: DateComponents(year: 2026, month: 10, day: day))! + return midnight.addingTimeInterval(.hours(hour)) + } + + private func bolus(_ units: Double, at start: Date, duration: TimeInterval = 0, automatic: Bool = false) -> SimpleInsulinDose { + SimpleInsulinDose(deliveryType: .bolus, automatic: automatic, startDate: start, endDate: start.addingTimeInterval(duration), volume: units, insulinModel: ExponentialInsulinModelPreset.rapidActingAdult) + } + + private func tempBasal(rate: Double, from start: Date, to end: Date) -> SimpleInsulinDose { + SimpleInsulinDose(deliveryType: .basal, automatic: true, startDate: start, endDate: end, volume: rate * end.timeIntervalSince(start).hours, insulinModel: ExponentialInsulinModelPreset.rapidActingAdult) + } + + private func scheduledBasal(_ rate: Double, from start: Date, to end: Date) -> [AbsoluteScheduleValue] { + [AbsoluteScheduleValue(startDate: start, endDate: end, value: rate)] + } + + func testBolusWithoutDurationOnlyCountsOnItsOwnDay() { + let history = InsulinDeliveryHistory( + doses: [bolus(5, at: date(20, 22)), bolus(2, at: date(21, 8))], + scheduledBasal: [], + coverageStart: date(20, 0), + end: date(21, 12) + ) + + XCTAssertEqual(history.totals(from: date(21, 0), to: date(21, 12)).total, 2, accuracy: 0.0001) + } + + func testBolusSpanningMidnightIsSplit() { + let history = InsulinDeliveryHistory( + doses: [bolus(4, at: date(20, 23.75), duration: .minutes(30))], + scheduledBasal: [], + coverageStart: date(20, 0), + end: date(21, 12) + ) + + XCTAssertEqual(history.totals(from: date(21, 0), to: date(21, 12)).bolus, 2, accuracy: 0.0001) + } + + func testUncoveredTimeCountsAsScheduledBasal() { + let history = InsulinDeliveryHistory( + doses: [ + tempBasal(rate: 0, from: date(21, 2), to: date(21, 4)), + tempBasal(rate: 3, from: date(21, 6), to: date(21, 7)), + ], + scheduledBasal: scheduledBasal(1, from: date(20, 0), to: date(22, 0)), + coverageStart: date(21, 0), + end: date(21, 10) + ) + + // 2 h scheduled, 2 h suspended, 2 h scheduled, 1 h at 3 U/h, 3 h scheduled + XCTAssertEqual(history.totals(from: date(21, 0), to: date(21, 10)).basal, 10, accuracy: 0.0001) + } + + func testOverlappingBasalRecordsAreNotDoubleCounted() { + let history = InsulinDeliveryHistory( + doses: [ + tempBasal(rate: 2, from: date(21, 1), to: date(21, 3)), + tempBasal(rate: 2, from: date(21, 2), to: date(21, 4)), + ], + scheduledBasal: scheduledBasal(1, from: date(21, 0), to: date(21, 5)), + coverageStart: date(21, 0), + end: date(21, 5) + ) + + XCTAssertEqual(history.totals(from: date(21, 0), to: date(21, 5)).basal, 8, accuracy: 0.0001) + } + + func testBolusesAreSplitByAutomation() { + let history = InsulinDeliveryHistory( + doses: [bolus(0.5, at: date(21, 1), automatic: true), bolus(0.3, at: date(21, 2), automatic: true), bolus(4, at: date(21, 3))], + scheduledBasal: [], + coverageStart: date(21, 0), + end: date(21, 5) + ) + + let totals = history.totals(from: date(21, 0), to: date(21, 5)) + XCTAssertEqual(totals.automaticBolus, 0.8, accuracy: 0.0001) + XCTAssertEqual(totals.manualBolus, 4, accuracy: 0.0001) + } + + func testDailyTotalsSkipThePartlyCoveredFirstDay() { + let history = InsulinDeliveryHistory( + doses: [bolus(3, at: date(19, 12)), bolus(4, at: date(20, 12)), bolus(5, at: date(21, 6))], + scheduledBasal: scheduledBasal(1, from: date(19, 0), to: date(22, 0)), + coverageStart: date(19, 9), + end: date(21, 12) + ) + + let days = history.dailyTotals(calendar: calendar) + XCTAssertEqual(days.map(\.start), [date(20, 0), date(21, 0)]) + XCTAssertEqual(days.map(\.isComplete), [true, false]) + XCTAssertEqual(days[0].totals.total, 28, accuracy: 0.0001) + XCTAssertEqual(days[1].totals.total, 17, accuracy: 0.0001) + } + + func testTypicalDeliveryUsesTheSameClockTimeOnEarlierDays() { + let breakfasts = [2.0, 6, 4, 8].enumerated().map { bolus($0.element, at: date(17 + $0.offset, 7)) } + let dinners = (17...20).map { bolus(10, at: date($0, 18)) } + let history = InsulinDeliveryHistory( + doses: breakfasts + dinners, + scheduledBasal: scheduledBasal(1, from: date(17, 0), to: date(22, 0)), + coverageStart: date(17, 0), + end: date(21, 9) + ) + + let typical = history.typicalDelivery(by: date(21, 9), calendar: calendar) + XCTAssertEqual(typical?.dayCount, 4) + XCTAssertEqual(typical?.median ?? 0, 9 + 5, accuracy: 0.0001) + } + + func testTypicalDeliveryNeedsEnoughDays() { + let history = InsulinDeliveryHistory( + doses: [], + scheduledBasal: scheduledBasal(1, from: date(19, 0), to: date(22, 0)), + coverageStart: date(19, 0), + end: date(21, 9) + ) + + XCTAssertNil(history.typicalDelivery(by: date(21, 9), calendar: calendar)) + XCTAssertEqual(history.typicalDelivery(by: date(21, 9), minimumDays: 2, calendar: calendar)?.median ?? 0, 9, accuracy: 0.0001) + } + + func testTypicalDeliveryKeepsClockTimeAcrossDaylightSavingTime() { + // Clocks fall back on 2026-11-01 in Chicago, so midnight to 9:00 that day is 10 hours. + let history = InsulinDeliveryHistory( + doses: [], + scheduledBasal: scheduledBasal(1, from: date(30, 0), to: calendar.date(from: DateComponents(year: 2026, month: 11, day: 3))!), + coverageStart: date(30, 0), + end: calendar.date(from: DateComponents(year: 2026, month: 11, day: 2, hour: 9))! + ) + + let typical = history.typicalDelivery(by: history.end, days: 3, minimumDays: 3, calendar: calendar) + XCTAssertEqual(typical?.median ?? 0, 9, accuracy: 0.0001) + } +}