Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Loop.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */

Expand Down Expand Up @@ -1319,6 +1322,7 @@
C16B983F26B4898800256B05 /* DoseEnactorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoseEnactorTests.swift; sourceTree = "<group>"; };
C16DA84122E8E112008624C2 /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = "<group>"; };
C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDataAlgorithmInput.swift; sourceTree = "<group>"; };
C4C16D18C8806FF48331134C /* InsulinDeliveryHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryHistory.swift; sourceTree = "<group>"; };
C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleInsulinDose.swift; sourceTree = "<group>"; };
C16FC0AF2A99392F0025E239 /* live_capture_input.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = live_capture_input.json; sourceTree = "<group>"; };
C1742331259BEADC00399C9D /* ManualEntryDoseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntryDoseView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1457,8 +1461,10 @@
FEEDF0000000000000000000 /* GlucoseStatistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStatistics.swift; sourceTree = "<group>"; };
FEEDF0000000000000000001 /* StatisticsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatisticsViewModel.swift; sourceTree = "<group>"; };
FEEDF0000000000000000002 /* Statistics/StatisticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Statistics/StatisticsView.swift; sourceTree = "<group>"; };
747DA22B61531CCAAC79E86D /* Statistics/DailyInsulinChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Statistics/DailyInsulinChartView.swift; sourceTree = "<group>"; };
FEEDF0000000000000000003 /* Statistics/AGPChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Statistics/AGPChartView.swift; sourceTree = "<group>"; };
FEEDF0000000000000000004 /* Statistics/TimeInRangeBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Statistics/TimeInRangeBar.swift; sourceTree = "<group>"; };
F69CE2EE3714858772FE7A26 /* InsulinDeliveryHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryHistoryTests.swift; sourceTree = "<group>"; };
FEEDF0000000000000000005 /* GlucoseStatisticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStatisticsTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -2614,6 +2622,7 @@
isa = PBXGroup;
children = (
C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */,
F69CE2EE3714858772FE7A26 /* InsulinDeliveryHistoryTests.swift */,
FEEDF0000000000000000005 /* GlucoseStatisticsTests.swift */,
A9DFAFB224F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift */,
A963B279252CEBAE0062AA12 /* SetBolusUserInfoTests.swift */,
Expand Down Expand Up @@ -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 */,
Expand All @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
6 changes: 5 additions & 1 deletion Loop/Managers/LoopAppManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}()

Expand Down
30 changes: 26 additions & 4 deletions Loop/Managers/LoopDataManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand Down
142 changes: 142 additions & 0 deletions Loop/Models/InsulinDeliveryHistory.swift
Original file line number Diff line number Diff line change
@@ -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<Double>]
/// Earliest time the dose records can be trusted to cover.
let coverageStart: Date
let end: Date

init(doses: [SimpleInsulinDose], scheduledBasal: [AbsoluteScheduleValue<Double>], 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]
}
}
2 changes: 2 additions & 0 deletions Loop/View Models/SettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
Loading