diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 2871a59de..a11667a1d 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -16,8 +16,8 @@ 656F8C102E49F36F0008DC1D /* QRCodeDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */; }; 656F8C122E49F3780008DC1D /* QRCodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */; }; 656F8C142E49F3D20008DC1D /* RemoteCommandSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */; }; - 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; }; 6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; }; + 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; }; 65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; }; DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; }; DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; }; @@ -408,8 +408,8 @@ 656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeDisplayView.swift; sourceTree = ""; }; 656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeGenerator.swift; sourceTree = ""; }; 656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteCommandSettings.swift; sourceTree = ""; }; - 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = ""; }; 6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = ""; }; + 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = ""; }; 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = ""; }; A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = ""; }; @@ -788,6 +788,8 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 65AC25F52ECFD5E800421360 /* Stats */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Stats; sourceTree = ""; }; + 65AC26702ED245DF00421360 /* Treatments */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Treatments; sourceTree = ""; }; DDCC3AD72DDE1790006F1C10 /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -1438,6 +1440,8 @@ FC8DEEE32485D1680075863F /* LoopFollow */ = { isa = PBXGroup; children = ( + 65AC26702ED245DF00421360 /* Treatments */, + 65AC25F52ECFD5E800421360 /* Stats */, DDCF9A7E2D85FCE6004DF4DD /* Alarm */, FC16A97624995FEE003D6245 /* Application */, DDFF3D792D140F1800BF9D9E /* BackgroundRefresh */, @@ -1592,6 +1596,10 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 65AC25F52ECFD5E800421360 /* Stats */, + 65AC26702ED245DF00421360 /* Treatments */, + ); name = LoopFollow; packageProductDependencies = ( DD48781B2C7DAF140048F05C /* SwiftJWT */, diff --git a/LoopFollow/Controllers/MainViewController+updateStats.swift b/LoopFollow/Controllers/MainViewController+updateStats.swift index 4f47cfc84..0b6b0d3b1 100644 --- a/LoopFollow/Controllers/MainViewController+updateStats.swift +++ b/LoopFollow/Controllers/MainViewController+updateStats.swift @@ -22,16 +22,16 @@ extension MainViewController { let stats = StatsData(bgData: lastDayOfData) - statsLowPercent.text = String(format: "%.1f%", stats.percentLow) + "%" - statsInRangePercent.text = String(format: "%.1f%", stats.percentRange) + "%" - statsHighPercent.text = String(format: "%.1f%", stats.percentHigh) + "%" - statsAvgBG.text = Localizer.toDisplayUnits(String(format: "%.0f%", stats.avgBG)) + statsLowPercent.text = String(format: "%.1f%%", stats.percentLow) + statsInRangePercent.text = String(format: "%.1f%%", stats.percentRange) + statsHighPercent.text = String(format: "%.1f%%", stats.percentHigh) + statsAvgBG.text = Localizer.toDisplayUnits(String(format: "%.0f", stats.avgBG)) if Storage.shared.useIFCC.value { - statsEstA1C.text = String(format: "%.0f%", stats.a1C) + statsEstA1C.text = String(format: "%.0f", stats.a1C) } else { - statsEstA1C.text = String(format: "%.1f%", stats.a1C) + statsEstA1C.text = String(format: "%.1f%%", stats.a1C) } - statsStdDev.text = String(format: "%.2f%", stats.stdDev) + statsStdDev.text = String(format: "%.2f", stats.stdDev) createStatsPie(pieData: stats.pie) } diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index 63efe5dad..8ff20df87 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -10,10 +10,12 @@ extension MainViewController { if !Storage.shared.downloadTreatments.value { return } let startTimeString = dateTimeUtils.getDateTimeString(addingDays: -1 * Storage.shared.downloadDays.value) - let currentTimeString = dateTimeUtils.getDateTimeString(addingHours: 6) + let currentTimeString = dateTimeUtils.getDateTimeString() + let estimatedCount = max(Storage.shared.downloadDays.value * 100, 5000) let parameters: [String: String] = [ "find[created_at][$gte]": startTimeString, "find[created_at][$lte]": currentTimeString, + "count": "\(estimatedCount)", ] NightscoutUtils.executeDynamicRequest(eventType: .treatments, parameters: parameters) { (result: Result) in switch result { diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 3af773889..5116bff46 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -282,6 +282,7 @@ private enum Sheet: Hashable, Identifiable { case calendar, contact case advanced case viewLog + case aggregatedStats var id: Self { self } @@ -301,10 +302,64 @@ private enum Sheet: Hashable, Identifiable { case .contact: ContactSettingsView(viewModel: .init()) case .advanced: AdvancedSettingsView(viewModel: .init()) case .viewLog: LogView(viewModel: .init()) + case .aggregatedStats: + AggregatedStatsViewWrapper() } } } +// Helper view to access MainViewController +struct AggregatedStatsViewWrapper: View { + @State private var mainViewController: MainViewController? + + var body: some View { + Group { + if let mainVC = mainViewController { + AggregatedStatsView(viewModel: AggregatedStatsViewModel(mainViewController: mainVC)) + } else { + Text("Loading stats...") + .onAppear { + mainViewController = getMainViewController() + } + } + } + } + + private func getMainViewController() -> MainViewController? { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootVC = window.rootViewController + else { + return nil + } + + if let mainVC = rootVC as? MainViewController { + return mainVC + } + + if let navVC = rootVC as? UINavigationController, + let mainVC = navVC.viewControllers.first as? MainViewController + { + return mainVC + } + + if let tabVC = rootVC as? UITabBarController { + for vc in tabVC.viewControllers ?? [] { + if let mainVC = vc as? MainViewController { + return mainVC + } + if let navVC = vc as? UINavigationController, + let mainVC = navVC.viewControllers.first as? MainViewController + { + return mainVC + } + } + } + + return nil + } +} + // MARK: – UIKit helpers (unchanged) import UIKit diff --git a/LoopFollow/Stats/AGP/AGPCalculator.swift b/LoopFollow/Stats/AGP/AGPCalculator.swift new file mode 100644 index 000000000..bfc7c1902 --- /dev/null +++ b/LoopFollow/Stats/AGP/AGPCalculator.swift @@ -0,0 +1,70 @@ +// LoopFollow +// AGPCalculator.swift + +import Foundation + +class AGPCalculator { + static func calculate(bgData: [ShareGlucoseData]) -> [AGPDataPoint] { + guard !bgData.isEmpty else { return [] } + + var hourData: [Int: [Double]] = [:] + let calendar = Calendar.current + + for reading in bgData { + let date = Date(timeIntervalSince1970: reading.date) + let components = calendar.dateComponents([.hour], from: date) + let hour = components.hour ?? 0 + + let glucose = Double(reading.sgv) + let glucoseMgdL = Storage.shared.units.value == "mg/dL" ? glucose : glucose * GlucoseConversion.mmolToMgDl + + if hourData[hour] == nil { + hourData[hour] = [] + } + hourData[hour]?.append(glucoseMgdL) + } + + var agpPoints: [AGPDataPoint] = [] + for hour in 0 ..< 24 { + guard let values = hourData[hour], !values.isEmpty else { continue } + + let sorted = values.sorted() + let p5 = PercentileCalculator.percentile(sorted, p: 0.05) + let p25 = PercentileCalculator.percentile(sorted, p: 0.25) + let p50 = PercentileCalculator.percentile(sorted, p: 0.50) + let p75 = PercentileCalculator.percentile(sorted, p: 0.75) + let p95 = PercentileCalculator.percentile(sorted, p: 0.95) + + let convert: (Double) -> Double = { value in + Storage.shared.units.value == "mg/dL" ? value : value * GlucoseConversion.mgDlToMmolL + } + + let minutesSinceMidnight = hour * 60 + + agpPoints.append(AGPDataPoint( + timeOfDay: minutesSinceMidnight, + p5: convert(p5), + p25: convert(p25), + p50: convert(p50), + p75: convert(p75), + p95: convert(p95) + )) + } + + return agpPoints.sorted { $0.timeOfDay < $1.timeOfDay } + } +} + +class PercentileCalculator { + static func percentile(_ sorted: [Double], p: Double) -> Double { + guard !sorted.isEmpty else { return 0.0 } + if sorted.count == 1 { return sorted[0] } + + let index = p * Double(sorted.count - 1) + let lower = Int(index.rounded(.down)) + let upper = min(lower + 1, sorted.count - 1) + let weight = index - Double(lower) + + return sorted[lower] * (1.0 - weight) + sorted[upper] * weight + } +} diff --git a/LoopFollow/Stats/AGP/AGPDataPoint.swift b/LoopFollow/Stats/AGP/AGPDataPoint.swift new file mode 100644 index 000000000..d88b7a635 --- /dev/null +++ b/LoopFollow/Stats/AGP/AGPDataPoint.swift @@ -0,0 +1,13 @@ +// LoopFollow +// AGPDataPoint.swift + +import Foundation + +struct AGPDataPoint { + let timeOfDay: Int + let p5: Double + let p25: Double + let p50: Double + let p75: Double + let p95: Double +} diff --git a/LoopFollow/Stats/AGP/AGPGraphView.swift b/LoopFollow/Stats/AGP/AGPGraphView.swift new file mode 100644 index 000000000..4d38b138c --- /dev/null +++ b/LoopFollow/Stats/AGP/AGPGraphView.swift @@ -0,0 +1,144 @@ +// LoopFollow +// AGPGraphView.swift + +import Charts +import SwiftUI + +struct AGPGraphView: UIViewRepresentable { + let agpData: [AGPDataPoint] + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context _: Context) -> UIView { + let containerView = NonInteractiveContainerView() + containerView.backgroundColor = .systemBackground + + let chartView = LineChartView() + chartView.rightAxis.enabled = true + chartView.leftAxis.enabled = false + chartView.xAxis.labelPosition = .bottom + chartView.rightAxis.drawGridLinesEnabled = false + chartView.leftAxis.drawGridLinesEnabled = false + chartView.xAxis.drawGridLinesEnabled = false + chartView.rightAxis.valueFormatter = ChartYMMOLValueFormatter() + chartView.legend.enabled = false + chartView.chartDescription.enabled = false + chartView.isUserInteractionEnabled = false + + containerView.addSubview(chartView) + chartView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + chartView.topAnchor.constraint(equalTo: containerView.topAnchor), + chartView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + chartView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + chartView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + ]) + + return containerView + } + + class Coordinator {} + + func updateUIView(_ containerView: UIView, context _: Context) { + guard let chartView = containerView.subviews.first as? LineChartView else { return } + guard !agpData.isEmpty else { return } + var p5Entries: [ChartDataEntry] = [] + var p25Entries: [ChartDataEntry] = [] + var p50Entries: [ChartDataEntry] = [] + var p75Entries: [ChartDataEntry] = [] + var p95Entries: [ChartDataEntry] = [] + + for point in agpData { + let x = Double(point.timeOfDay) / 60.0 + p5Entries.append(ChartDataEntry(x: x, y: point.p5)) + p25Entries.append(ChartDataEntry(x: x, y: point.p25)) + p50Entries.append(ChartDataEntry(x: x, y: point.p50)) + p75Entries.append(ChartDataEntry(x: x, y: point.p75)) + p95Entries.append(ChartDataEntry(x: x, y: point.p95)) + } + + let sortedP5 = p5Entries.sorted { $0.x < $1.x } + let sortedP25 = p25Entries.sorted { $0.x < $1.x } + let sortedP50 = p50Entries.sorted { $0.x < $1.x } + let sortedP75 = p75Entries.sorted { $0.x < $1.x } + let sortedP95 = p95Entries.sorted { $0.x < $1.x } + + guard !sortedP5.isEmpty, !sortedP25.isEmpty, !sortedP50.isEmpty, + !sortedP75.isEmpty, !sortedP95.isEmpty + else { + return + } + let p5DataSet = LineChartDataSet(entries: sortedP5, label: "5th") + p5DataSet.colors = [NSUIColor.systemGray.withAlphaComponent(0.6)] + p5DataSet.lineWidth = 1.5 + p5DataSet.drawCirclesEnabled = false + p5DataSet.drawValuesEnabled = false + p5DataSet.drawFilledEnabled = false + p5DataSet.mode = .linear + + let p25DataSet = LineChartDataSet(entries: sortedP25, label: "25th") + p25DataSet.colors = [NSUIColor.systemBlue.withAlphaComponent(0.7)] + p25DataSet.lineWidth = 1.5 + p25DataSet.drawCirclesEnabled = false + p25DataSet.drawValuesEnabled = false + p25DataSet.drawFilledEnabled = false + p25DataSet.mode = .linear + + let p50DataSet = LineChartDataSet(entries: sortedP50, label: "Median") + p50DataSet.colors = [NSUIColor.systemBlue] + p50DataSet.lineWidth = 3 + p50DataSet.drawCirclesEnabled = false + p50DataSet.drawValuesEnabled = false + p50DataSet.drawFilledEnabled = false + p50DataSet.mode = .linear + + let p75DataSet = LineChartDataSet(entries: sortedP75, label: "75th") + p75DataSet.colors = [NSUIColor.systemBlue.withAlphaComponent(0.7)] + p75DataSet.lineWidth = 1.5 + p75DataSet.drawCirclesEnabled = false + p75DataSet.drawValuesEnabled = false + p75DataSet.drawFilledEnabled = false + p75DataSet.mode = .linear + + let p95DataSet = LineChartDataSet(entries: sortedP95, label: "95th") + p95DataSet.colors = [NSUIColor.systemGray.withAlphaComponent(0.6)] + p95DataSet.lineWidth = 1.5 + p95DataSet.drawCirclesEnabled = false + p95DataSet.drawValuesEnabled = false + p95DataSet.drawFilledEnabled = false + p95DataSet.mode = .linear + let maxY = max(sortedP95.map { $0.y }.max() ?? 300, 300) + 10 + let hourMinY = min(sortedP5.map { $0.y }.min() ?? 0, 0) - 10 + + var hourLines: [ChartDataEntry] = [] + for hour in 0 ... 24 { + let x = Double(hour) + hourLines.append(ChartDataEntry(x: x, y: hourMinY)) + hourLines.append(ChartDataEntry(x: x, y: maxY)) + if hour < 24 { + hourLines.append(ChartDataEntry(x: x + 0.0001, y: hourMinY)) + } + } + + let hourLinesDataSet = LineChartDataSet(entries: hourLines, label: "Hours") + hourLinesDataSet.colors = [NSUIColor.label.withAlphaComponent(0.3)] + hourLinesDataSet.lineWidth = 1 + hourLinesDataSet.drawCirclesEnabled = false + hourLinesDataSet.drawValuesEnabled = false + hourLinesDataSet.drawFilledEnabled = false + + let data = LineChartData() + data.append(p5DataSet) + data.append(p25DataSet) + data.append(p50DataSet) + data.append(p75DataSet) + data.append(p95DataSet) + data.append(hourLinesDataSet) + + chartView.data = data + chartView.notifyDataSetChanged() + chartView.setNeedsDisplay() + } +} diff --git a/LoopFollow/Stats/AGP/AGPView.swift b/LoopFollow/Stats/AGP/AGPView.swift new file mode 100644 index 000000000..6179b7b39 --- /dev/null +++ b/LoopFollow/Stats/AGP/AGPView.swift @@ -0,0 +1,50 @@ +// LoopFollow +// AGPView.swift + +import SwiftUI + +struct AGPView: View { + @ObservedObject var viewModel: AGPViewModel + + var body: some View { + if !viewModel.agpData.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Ambulatory Glucose Profile (AGP)") + .font(.caption) + .foregroundColor(.secondary) + + AGPGraphView(agpData: viewModel.agpData) + .frame(height: 200) + .allowsHitTesting(false) + .clipped() + + // Legend + HStack(spacing: 16) { + LegendItem(color: .gray.opacity(0.6), label: "5th-95th") + LegendItem(color: .blue.opacity(0.7), label: "25th-75th") + LegendItem(color: .blue, label: "Median") + } + .font(.caption2) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(12) + } + } +} + +struct LegendItem: View { + let color: Color + let label: String + + var body: some View { + HStack(spacing: 4) { + Rectangle() + .fill(color) + .frame(width: 12, height: 12) + Text(label) + .foregroundColor(.secondary) + } + } +} diff --git a/LoopFollow/Stats/AGP/AGPViewModel.swift b/LoopFollow/Stats/AGP/AGPViewModel.swift new file mode 100644 index 000000000..3a5d06cd1 --- /dev/null +++ b/LoopFollow/Stats/AGP/AGPViewModel.swift @@ -0,0 +1,21 @@ +// LoopFollow +// AGPViewModel.swift + +import Combine +import Foundation + +class AGPViewModel: ObservableObject { + @Published var agpData: [AGPDataPoint] = [] + + private let dataService: StatsDataService + + init(dataService: StatsDataService) { + self.dataService = dataService + calculateAGP() + } + + func calculateAGP() { + let bgData = dataService.getBGData() + agpData = AGPCalculator.calculate(bgData: bgData) + } +} diff --git a/LoopFollow/Stats/AggregatedStatsView.swift b/LoopFollow/Stats/AggregatedStatsView.swift new file mode 100644 index 000000000..3f87e05cb --- /dev/null +++ b/LoopFollow/Stats/AggregatedStatsView.swift @@ -0,0 +1,357 @@ +// LoopFollow +// AggregatedStatsView.swift + +import SwiftUI +import UIKit + +struct AggregatedStatsView: View { + @ObservedObject var viewModel: AggregatedStatsViewModel + @Environment(\.dismiss) var dismiss + @State private var showGMI: Bool + @State private var showStdDev: Bool + @State private var selectedPeriod = 14 + @State private var isLoadingData = false + + init(viewModel: AggregatedStatsViewModel) { + self.viewModel = viewModel + _showGMI = State(initialValue: Storage.shared.showGMI.value) + _showStdDev = State(initialValue: Storage.shared.showStdDev.value) + } + + var body: some View { + ScrollView { + VStack(spacing: 20) { + VStack(spacing: 8) { + Text("Statistics") + .font(.largeTitle) + .fontWeight(.bold) + + Picker("Period", selection: $selectedPeriod) { + Text("1 day").tag(1) + Text("14 days").tag(14) + Text("30 days").tag(30) + } + .pickerStyle(.segmented) + .padding(.horizontal) + .onChange(of: selectedPeriod) { newValue in + isLoadingData = true + viewModel.updatePeriod(newValue) { + isLoadingData = false + } + } + } + .padding(.top) + + if isLoadingData { + ProgressView("Loading data...") + .padding() + } + + StatsGridView( + simpleStats: viewModel.simpleStats, + showGMI: $showGMI, + showStdDev: $showStdDev + ) + .padding(.horizontal) + + AGPView(viewModel: viewModel.agpStats) + .padding(.horizontal) + + TIRView(viewModel: viewModel.tirStats) + .padding(.horizontal) + + GRIView(viewModel: viewModel.griStats) + .padding(.horizontal) + } + .padding(.bottom) + .frame(maxWidth: .infinity) + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Refresh") { + viewModel.calculateStats() + } + } + } + .onAppear { + viewModel.dataService.ensureDataAvailable( + onProgress: {}, + completion: { + viewModel.calculateStats() + } + ) + } + } +} + +struct StatCard: View { + let title: String + let value: String + let unit: String? + let color: Color + var isInteractive: Bool = false + + var body: some View { + ZStack(alignment: .topTrailing) { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.caption) + .foregroundColor(.secondary) + + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(value) + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(color) + + if let unit = unit { + Text(unit) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + + if isInteractive { + Image(systemName: "chevron.up.chevron.down") + .font(.caption2) + .foregroundColor(.secondary.opacity(0.5)) + .padding(8) + } + } + .background(Color(.systemGray6)) + .cornerRadius(12) + } +} + +struct BasalComparisonCard: View { + let programmed: Double? + let actual: Double? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Basal Comparison") + .font(.caption) + .foregroundColor(.secondary) + + VStack(spacing: 8) { + HStack { + Text("Programmed") + .font(.subheadline) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + + Text("Actual Delivered") + .font(.subheadline) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } + + HStack { + VStack(spacing: 2) { + Text(formatBasal(programmed)) + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.blue) + Text("U") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .center) + + VStack(spacing: 2) { + Text(formatBasal(actual)) + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.green) + Text("U") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .center) + } + + if let prog = programmed, let act = actual, prog > 0 { + let diff = act - prog + let percentDiff = (diff / prog) * 100 + HStack { + Spacer() + Text(String(format: "%.2f U (%.1f%%)", diff, percentDiff)) + .font(.caption) + .foregroundColor(diff > 0 ? .red : .green) + Spacer() + } + .padding(.top, 4) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(12) + } + + private func formatBasal(_ value: Double?) -> String { + guard let value = value else { return "---" } + return String(format: "%.2f", value) + } +} + +struct StatsGridView: View { + @ObservedObject var simpleStats: SimpleStatsViewModel + @Binding var showGMI: Bool + @Binding var showStdDev: Bool + + private var hasInsulinData: Bool { + simpleStats.totalDailyDose != nil || simpleStats.avgBolus != nil || simpleStats.actualBasal != nil + } + + private var hasCarbData: Bool { + simpleStats.avgCarbs != nil + } + + var body: some View { + VStack(spacing: 16) { + HStack(spacing: 16) { + Button(action: { + showGMI.toggle() + Storage.shared.showGMI.value = showGMI + }) { + StatCard( + title: showGMI ? "GMI" : "eHbA1c", + value: showGMI ? formatGMI(simpleStats.gmi) : formatEhbA1c(simpleStats.avgGlucose), + unit: showGMI ? "%" : (Storage.shared.units.value == "mg/dL" ? "%" : "mmol/mol"), + color: .blue, + isInteractive: true + ) + } + .buttonStyle(PlainButtonStyle()) + + StatCard( + title: "Avg Glucose", + value: formatGlucose(simpleStats.avgGlucose), + unit: Storage.shared.units.value, + color: .green + ) + } + + HStack(spacing: 16) { + Button(action: { + showStdDev.toggle() + Storage.shared.showStdDev.value = showStdDev + }) { + StatCard( + title: showStdDev ? "Std Deviation" : "CV", + value: showStdDev ? formatStdDev(simpleStats.stdDeviation) : formatCV(simpleStats.coefficientOfVariation), + unit: showStdDev ? Storage.shared.units.value : "%", + color: .orange, + isInteractive: true + ) + } + .buttonStyle(PlainButtonStyle()) + + if hasInsulinData { + StatCard( + title: "Total Daily Dose", + value: formatInsulin(simpleStats.totalDailyDose), + unit: "U", + color: .red + ) + } else { + Color.clear + .frame(maxWidth: .infinity) + } + } + + if hasInsulinData || hasCarbData { + HStack(spacing: 16) { + if hasInsulinData { + StatCard( + title: "Avg Bolus", + value: formatInsulin(simpleStats.avgBolus), + unit: "U/day", + color: .purple + ) + } + + if hasCarbData { + StatCard( + title: "Avg Carbs", + value: formatCarbs(simpleStats.avgCarbs), + unit: "g/day", + color: .orange + ) + } + } + } + + if hasInsulinData { + BasalComparisonCard( + programmed: simpleStats.programmedBasal, + actual: simpleStats.actualBasal + ) + } + } + } + + private func formatGMI(_ value: Double?) -> String { + guard let value = value else { return "---" } + return String(format: "%.1f", value) + } + + private func formatEhbA1c(_ avgGlucose: Double?) -> String { + guard let avgGlucose = avgGlucose else { return "---" } + + let avgGlucoseMgdL: Double + if Storage.shared.units.value == "mg/dL" { + avgGlucoseMgdL = avgGlucose + } else { + avgGlucoseMgdL = avgGlucose * 18.0182 + } + + let ehba1cPercent = (avgGlucoseMgdL + 46.7) / 28.7 + + if Storage.shared.units.value == "mg/dL" { + return String(format: "%.1f", ehba1cPercent) + } else { + let ehba1cMmolMol = (ehba1cPercent - 2.15) * 10.929 + return String(format: "%.0f", ehba1cMmolMol) + } + } + + private func formatGlucose(_ value: Double?) -> String { + guard let value = value else { return "---" } + if Storage.shared.units.value == "mg/dL" { + return String(format: "%.0f", value) + } else { + return String(format: "%.1f", value) + } + } + + private func formatStdDev(_ value: Double?) -> String { + guard let value = value else { return "---" } + if Storage.shared.units.value == "mg/dL" { + return String(format: "%.0f", value) + } else { + return String(format: "%.1f", value) + } + } + + private func formatInsulin(_ value: Double?) -> String { + guard let value = value else { return "---" } + return String(format: "%.2f", value) + } + + private func formatCarbs(_ value: Double?) -> String { + guard let value = value else { return "---" } + return String(format: "%.0f", value) + } + + private func formatCV(_ value: Double?) -> String { + guard let value = value else { return "---" } + return String(format: "%.1f", value) + } +} diff --git a/LoopFollow/Stats/AggregatedStatsViewModel.swift b/LoopFollow/Stats/AggregatedStatsViewModel.swift new file mode 100644 index 000000000..13dfde2b6 --- /dev/null +++ b/LoopFollow/Stats/AggregatedStatsViewModel.swift @@ -0,0 +1,55 @@ +// LoopFollow +// AggregatedStatsViewModel.swift + +import Combine +import Foundation + +class AggregatedStatsViewModel: ObservableObject { + var simpleStats: SimpleStatsViewModel + var agpStats: AGPViewModel + var griStats: GRIViewModel + var tirStats: TIRViewModel + + let dataService: StatsDataService + + init(mainViewController: MainViewController?) { + dataService = StatsDataService(mainViewController: mainViewController) + simpleStats = SimpleStatsViewModel(dataService: dataService) + agpStats = AGPViewModel(dataService: dataService) + griStats = GRIViewModel(dataService: dataService) + tirStats = TIRViewModel(dataService: dataService) + } + + func calculateStats() { + simpleStats.calculateStats() + agpStats.calculateAGP() + griStats.calculateGRI() + tirStats.calculateTIR() + } + + func updatePeriod(_ days: Int, completion: @escaping () -> Void = {}) { + dataService.daysToAnalyze = days + dataService.ensureDataAvailable( + onProgress: {}, + completion: { + self.calculateStats() + completion() + } + ) + } + + var gmi: Double? { simpleStats.gmi } + var avgGlucose: Double? { simpleStats.avgGlucose } + var stdDeviation: Double? { simpleStats.stdDeviation } + var coefficientOfVariation: Double? { simpleStats.coefficientOfVariation } + var totalDailyDose: Double? { simpleStats.totalDailyDose } + var programmedBasal: Double? { simpleStats.programmedBasal } + var actualBasal: Double? { simpleStats.actualBasal } + var avgBolus: Double? { simpleStats.avgBolus } + var avgCarbs: Double? { simpleStats.avgCarbs } + var agpData: [AGPDataPoint] { agpStats.agpData } + var gri: Double? { griStats.gri } + var griHypoComponent: Double? { griStats.griHypoComponent } + var griHyperComponent: Double? { griStats.griHyperComponent } + var griDataPoints: [(date: Date, value: Double)] { griStats.griDataPoints } +} diff --git a/LoopFollow/Stats/ChartContainerView.swift b/LoopFollow/Stats/ChartContainerView.swift new file mode 100644 index 000000000..e201e954e --- /dev/null +++ b/LoopFollow/Stats/ChartContainerView.swift @@ -0,0 +1,25 @@ +// LoopFollow +// ChartContainerView.swift + +import Charts +import UIKit + +class NonInteractiveContainerView: UIView { + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + isUserInteractionEnabled = false + } + + override func hitTest(_: CGPoint, with _: UIEvent?) -> UIView? { + return nil + } +} diff --git a/LoopFollow/Stats/GRI/GRICalculator.swift b/LoopFollow/Stats/GRI/GRICalculator.swift new file mode 100644 index 000000000..9726ba38c --- /dev/null +++ b/LoopFollow/Stats/GRI/GRICalculator.swift @@ -0,0 +1,84 @@ +// LoopFollow +// GRICalculator.swift + +import Foundation + +struct GRICalculationResult { + let gri: Double + let hypoComponent: Double + let hyperComponent: Double +} + +class GRICalculator { + /// Calculate GRI (Glucose Risk Index) from BG data + /// GRI = (3.0 × HypoComponent) + (1.6 × HyperComponent) + static func calculate(bgData: [ShareGlucoseData]) -> GRICalculationResult { + guard !bgData.isEmpty else { return GRICalculationResult(gri: 0.0, hypoComponent: 0.0, hyperComponent: 0.0) } + + let vLowThreshold = 54.0 + let lowThreshold = 70.0 + let highThreshold = 180.0 + let vHighThreshold = 250.0 + + var vLowCount = 0 + var lowCount = 0 + var highCount = 0 + var vHighCount = 0 + + for reading in bgData { + let glucose = Double(reading.sgv) + + if glucose < vLowThreshold { + vLowCount += 1 + } else if glucose < lowThreshold { + lowCount += 1 + } else if glucose > vHighThreshold { + vHighCount += 1 + } else if glucose > highThreshold { + highCount += 1 + } + } + + let totalCount = Double(bgData.count) + guard totalCount > 0 else { return GRICalculationResult(gri: 0.0, hypoComponent: 0.0, hyperComponent: 0.0) } + + let vLowPercent = (Double(vLowCount) / totalCount) * 100.0 + let lowPercent = (Double(lowCount) / totalCount) * 100.0 + let highPercent = (Double(highCount) / totalCount) * 100.0 + let vHighPercent = (Double(vHighCount) / totalCount) * 100.0 + + let hypoComponent = vLowPercent + (0.8 * lowPercent) + let hyperComponent = vHighPercent + (0.5 * highPercent) + + let gri = (3.0 * hypoComponent) + (1.6 * hyperComponent) + return GRICalculationResult( + gri: min(gri, 100.0), + hypoComponent: hypoComponent, + hyperComponent: hyperComponent + ) + } + + static func calculateTimeSeries(bgData: [ShareGlucoseData]) -> [(date: Date, value: Double)] { + guard !bgData.isEmpty else { return [] } + + var dailyBGData: [Date: [ShareGlucoseData]] = [:] + let calendar = Calendar.current + + for reading in bgData { + let date = Date(timeIntervalSince1970: reading.date) + let dayStart = calendar.startOfDay(for: date) + if dailyBGData[dayStart] == nil { + dailyBGData[dayStart] = [] + } + dailyBGData[dayStart]?.append(reading) + } + + var griPoints: [(date: Date, value: Double)] = [] + for (date, dayData) in dailyBGData.sorted(by: { $0.key < $1.key }) { + let dayGRIResult = calculate(bgData: dayData) + griPoints.append((date: date, value: dayGRIResult.gri)) + } + + return griPoints + } +} diff --git a/LoopFollow/Stats/GRI/GRIRiskGridView.swift b/LoopFollow/Stats/GRI/GRIRiskGridView.swift new file mode 100644 index 000000000..31559b4fd --- /dev/null +++ b/LoopFollow/Stats/GRI/GRIRiskGridView.swift @@ -0,0 +1,138 @@ +// LoopFollow +// GRIRiskGridView.swift + +import Charts +import SwiftUI + +struct GRIRiskGridView: UIViewRepresentable { + let hypoComponent: Double + let hyperComponent: Double + let gri: Double + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context _: Context) -> UIView { + let containerView = NonInteractiveContainerView() + containerView.backgroundColor = .systemBackground + + let chartView = ScatterChartView() + chartView.backgroundColor = .systemBackground + chartView.rightAxis.enabled = false + chartView.leftAxis.enabled = true + chartView.legend.enabled = false + chartView.chartDescription.enabled = false + chartView.leftAxis.axisMinimum = 0 + chartView.leftAxis.axisMaximum = 60 + chartView.leftAxis.forceLabelsEnabled = true + chartView.leftAxis.labelPosition = .outsideChart + chartView.rightAxis.enabled = false + chartView.xAxis.labelPosition = .bottom + chartView.xAxis.axisMinimum = 0 + chartView.xAxis.axisMaximum = 30 + chartView.isUserInteractionEnabled = false + + containerView.addSubview(chartView) + chartView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + chartView.topAnchor.constraint(equalTo: containerView.topAnchor), + chartView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + chartView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + chartView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + ]) + + return containerView + } + + class Coordinator {} + + func updateUIView(_ containerView: UIView, context _: Context) { + guard let chartView = containerView.subviews.first as? ScatterChartView else { return } + + chartView.data = nil + + var zoneAEntries: [ChartDataEntry] = [] + var zoneBEntries: [ChartDataEntry] = [] + var zoneCEntries: [ChartDataEntry] = [] + var zoneDEntries: [ChartDataEntry] = [] + var zoneEEntries: [ChartDataEntry] = [] + + let step = 0.5 + for hypo in stride(from: 0.0, through: 30.0, by: step) { + for hyper in stride(from: 0.0, through: 60.0, by: step) { + let griValue = (3.0 * hypo) + (1.6 * hyper) + + guard griValue <= 100 else { continue } + + let entry = ChartDataEntry(x: hypo, y: hyper) + + if griValue <= 20 { + zoneAEntries.append(entry) + } else if griValue <= 40 { + zoneBEntries.append(entry) + } else if griValue <= 60 { + zoneCEntries.append(entry) + } else if griValue <= 80 { + zoneDEntries.append(entry) + } else { + zoneEEntries.append(entry) + } + } + } + + let zoneADataSet = ScatterChartDataSet(entries: zoneAEntries, label: "Zone A") + zoneADataSet.setColor(NSUIColor.systemGreen.withAlphaComponent(0.3)) + zoneADataSet.scatterShapeSize = 4 + zoneADataSet.drawValuesEnabled = false + + let zoneBDataSet = ScatterChartDataSet(entries: zoneBEntries, label: "Zone B") + zoneBDataSet.setColor(NSUIColor.systemYellow.withAlphaComponent(0.3)) + zoneBDataSet.scatterShapeSize = 4 + zoneBDataSet.drawValuesEnabled = false + + let zoneCDataSet = ScatterChartDataSet(entries: zoneCEntries, label: "Zone C") + zoneCDataSet.setColor(NSUIColor.systemOrange.withAlphaComponent(0.3)) + zoneCDataSet.scatterShapeSize = 4 + zoneCDataSet.drawValuesEnabled = false + + let zoneDDataSet = ScatterChartDataSet(entries: zoneDEntries, label: "Zone D") + zoneDDataSet.setColor(NSUIColor.systemRed.withAlphaComponent(0.3)) + zoneDDataSet.scatterShapeSize = 4 + zoneDDataSet.drawValuesEnabled = false + + let zoneEDataSet = ScatterChartDataSet(entries: zoneEEntries, label: "Zone E") + zoneEDataSet.setColor(NSUIColor.systemRed.withAlphaComponent(0.5)) + zoneEDataSet.scatterShapeSize = 4 + zoneEDataSet.drawValuesEnabled = false + + let currentPoint = ChartDataEntry(x: hypoComponent, y: hyperComponent) + let currentDataSet = ScatterChartDataSet(entries: [currentPoint], label: "Current GRI") + currentDataSet.setColor(NSUIColor.label) + currentDataSet.scatterShapeSize = 12 + currentDataSet.setScatterShape(.circle) + currentDataSet.drawValuesEnabled = false + + let data = ScatterChartData() + data.append(zoneADataSet) + data.append(zoneBDataSet) + data.append(zoneCDataSet) + data.append(zoneDDataSet) + data.append(zoneEDataSet) + data.append(currentDataSet) + + chartView.data = data + + chartView.xAxis.valueFormatter = DefaultAxisValueFormatter { value, _ in + String(format: "%.0f", value) + } + chartView.leftAxis.valueFormatter = DefaultAxisValueFormatter { value, _ in + String(format: "%.0f", value) + } + + chartView.xAxis.labelTextColor = .label + chartView.leftAxis.labelTextColor = .label + + chartView.notifyDataSetChanged() + } +} diff --git a/LoopFollow/Stats/GRI/GRIView.swift b/LoopFollow/Stats/GRI/GRIView.swift new file mode 100644 index 000000000..a452bc105 --- /dev/null +++ b/LoopFollow/Stats/GRI/GRIView.swift @@ -0,0 +1,116 @@ +// LoopFollow +// GRIView.swift + +import SwiftUI + +struct GRIView: View { + @ObservedObject var viewModel: GRIViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("GRI (Glucose Risk Index)") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + if let gri = viewModel.gri { + VStack(alignment: .trailing, spacing: 2) { + Text(String(format: "%.0f", gri)) + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(griColor(gri)) + Text(griZone(gri)) + .font(.caption2) + .foregroundColor(.secondary) + } + } else { + Text("---") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.purple) + } + } + + if let hypo = viewModel.griHypoComponent, let hyper = viewModel.griHyperComponent { + GRIRiskGridView( + hypoComponent: hypo, + hyperComponent: hyper, + gri: viewModel.gri ?? 0 + ) + .frame(height: 250) + .allowsHitTesting(false) + .clipped() + HStack { + Text("Hypoglycemia Component (%)") + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + Text("Hyperglycemia Component (%)") + .font(.caption2) + .foregroundColor(.secondary) + } + + HStack(spacing: 12) { + ZoneLegendItem(color: .green, label: "A (0-20)") + ZoneLegendItem(color: .yellow, label: "B (21-40)") + ZoneLegendItem(color: .orange, label: "C (41-60)") + ZoneLegendItem(color: .red, label: "D (61-80)") + ZoneLegendItem(color: .red.opacity(0.8), label: "E (81-100)") + } + .font(.caption2) + } else { + Text("No data available") + .font(.caption) + .foregroundColor(.secondary) + .frame(height: 250) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(12) + } + + private func griColor(_ gri: Double) -> Color { + if gri <= 20 { + return .green + } else if gri <= 40 { + return .yellow + } else if gri <= 60 { + return .orange + } else if gri <= 80 { + return .red + } else { + return .red.opacity(0.8) + } + } + + private func griZone(_ gri: Double) -> String { + if gri <= 20 { + return "Zone A" + } else if gri <= 40 { + return "Zone B" + } else if gri <= 60 { + return "Zone C" + } else if gri <= 80 { + return "Zone D" + } else { + return "Zone E" + } + } +} + +struct ZoneLegendItem: View { + let color: Color + let label: String + + var body: some View { + HStack(spacing: 4) { + Rectangle() + .fill(color.opacity(0.3)) + .frame(width: 12, height: 12) + Text(label) + .foregroundColor(.secondary) + } + } +} diff --git a/LoopFollow/Stats/GRI/GRIViewModel.swift b/LoopFollow/Stats/GRI/GRIViewModel.swift new file mode 100644 index 000000000..e0a7c0e3c --- /dev/null +++ b/LoopFollow/Stats/GRI/GRIViewModel.swift @@ -0,0 +1,31 @@ +// LoopFollow +// GRIViewModel.swift + +import Combine +import Foundation + +class GRIViewModel: ObservableObject { + @Published var gri: Double? + @Published var griHypoComponent: Double? + @Published var griHyperComponent: Double? + @Published var griDataPoints: [(date: Date, value: Double)] = [] + + private let dataService: StatsDataService + + init(dataService: StatsDataService) { + self.dataService = dataService + calculateGRI() + } + + func calculateGRI() { + let bgData = dataService.getBGData() + guard !bgData.isEmpty else { return } + + let result = GRICalculator.calculate(bgData: bgData) + gri = result.gri + griHypoComponent = result.hypoComponent + griHyperComponent = result.hyperComponent + + griDataPoints = GRICalculator.calculateTimeSeries(bgData: bgData) + } +} diff --git a/LoopFollow/Stats/SimpleStatsViewModel.swift b/LoopFollow/Stats/SimpleStatsViewModel.swift new file mode 100644 index 000000000..cbc14eb65 --- /dev/null +++ b/LoopFollow/Stats/SimpleStatsViewModel.swift @@ -0,0 +1,228 @@ +// LoopFollow +// SimpleStatsViewModel.swift + +import Combine +import Foundation + +class SimpleStatsViewModel: ObservableObject { + @Published var gmi: Double? + @Published var avgGlucose: Double? + @Published var stdDeviation: Double? + @Published var coefficientOfVariation: Double? + @Published var totalDailyDose: Double? + @Published var programmedBasal: Double? + @Published var actualBasal: Double? + @Published var avgBolus: Double? + @Published var avgCarbs: Double? + + private let dataService: StatsDataService + + init(dataService: StatsDataService) { + self.dataService = dataService + } + + func calculateStats() { + let bgData = dataService.getBGData() + guard !bgData.isEmpty else { return } + + let totalGlucose = bgData.reduce(0) { $0 + $1.sgv } + let avgBGmgdL = Double(totalGlucose) / Double(bgData.count) + avgGlucose = Storage.shared.units.value == "mg/dL" ? avgBGmgdL : avgBGmgdL * GlucoseConversion.mgDlToMmolL + + let variance = bgData.reduce(0.0) { sum, reading in + let diff = Double(reading.sgv) - avgBGmgdL + return sum + (diff * diff) + } + let stdDevMgdL = sqrt(variance / Double(bgData.count)) + stdDeviation = Storage.shared.units.value == "mg/dL" ? stdDevMgdL : stdDevMgdL * GlucoseConversion.mgDlToMmolL + + gmi = 3.31 + (0.02392 * avgBGmgdL) + + if avgBGmgdL > 0 { + coefficientOfVariation = (stdDevMgdL / avgBGmgdL) * 100.0 + } else { + coefficientOfVariation = nil + } + + let bolusesInPeriod = dataService.getBolusData() + let smbInPeriod = dataService.getSMBData() + let bolusTotal = bolusesInPeriod.reduce(0.0) { $0 + $1.value } + let smbTotal = smbInPeriod.reduce(0.0) { $0 + $1.value } + let totalBolusInPeriod = bolusTotal + smbTotal + + let cutoffTime = Date().timeIntervalSince1970 - (Double(dataService.daysToAnalyze) * 24 * 60 * 60) + let allBolusDates = (bolusesInPeriod + smbInPeriod).map { $0.date }.filter { $0 >= cutoffTime } + let actualDays = calculateActualDaysCovered(dates: allBolusDates, requestedDays: dataService.daysToAnalyze) + + if actualDays > 0 { + avgBolus = totalBolusInPeriod / Double(actualDays) + } else { + avgBolus = nil + } + + let carbsInPeriod = dataService.getCarbData() + + let calendar = Calendar.current + var dailyCarbs: [Date: Double] = [:] + + for carb in carbsInPeriod { + let carbDate = Date(timeIntervalSince1970: carb.date) + let dayStart = calendar.startOfDay(for: carbDate) + + if dailyCarbs[dayStart] == nil { + dailyCarbs[dayStart] = 0.0 + } + dailyCarbs[dayStart]? += carb.value + } + + let totalCarbsInPeriod = dailyCarbs.values.reduce(0.0, +) + + let daysWithData = max(dailyCarbs.count, 1) + + if daysWithData > 0 { + avgCarbs = totalCarbsInPeriod / Double(daysWithData) + } else { + avgCarbs = nil + } + + let basalDataInPeriod = dataService.getBasalData() + let totalBasalOverPeriod = calculateTotalBasal(basalData: basalDataInPeriod) + + let basalDates = basalDataInPeriod.map { $0.date }.filter { $0 >= cutoffTime } + let actualBasalDays = calculateActualDaysCovered(dates: basalDates, requestedDays: dataService.daysToAnalyze) + + var avgDailyBolus = 0.0 + var avgDailyBasal = 0.0 + + if actualDays > 0 { + avgDailyBolus = totalBolusInPeriod / Double(actualDays) + } + + if actualBasalDays > 0 { + avgDailyBasal = totalBasalOverPeriod / Double(actualBasalDays) + actualBasal = avgDailyBasal + } else { + actualBasal = nil + } + + if actualDays > 0 || actualBasalDays > 0 { + totalDailyDose = avgDailyBolus + avgDailyBasal + } else { + totalDailyDose = nil + } + + let basalProfile = dataService.getBasalProfile() + programmedBasal = calculateProgrammedBasalFromProfile(basalProfile: basalProfile) + } + + private func calculateTotalBasal(basalData: [MainViewController.basalGraphStruct]) -> Double { + guard !basalData.isEmpty else { return 0.0 } + + var totalBasal = 0.0 + let cutoffTime = Date().timeIntervalSince1970 - (Double(dataService.daysToAnalyze) * 24 * 60 * 60) + let now = Date().timeIntervalSince1970 + + let basalProfile = dataService.getBasalProfile() + + let sortedBasal = basalData.sorted { $0.date < $1.date } + + for i in 0 ..< sortedBasal.count { + let current = sortedBasal[i] + let startTime = max(current.date, cutoffTime) + + let endTime: TimeInterval + if i < sortedBasal.count - 1 { + endTime = min(sortedBasal[i + 1].date, now) + } else { + endTime = now + } + + if endTime > startTime { + let durationHours = (endTime - startTime) / 3600.0 + + let scheduledBasalRate = getScheduledBasalRate(for: startTime, profile: basalProfile) + + let adjustment = current.basalRate - scheduledBasalRate + + totalBasal += scheduledBasalRate * durationHours + totalBasal += adjustment * durationHours + } + } + + return totalBasal + } + + private func getScheduledBasalRate(for time: TimeInterval, profile: [MainViewController.basalProfileStruct]) -> Double { + guard !profile.isEmpty else { return 0.0 } + + let calendar = Calendar.current + let date = Date(timeIntervalSince1970: time) + let components = calendar.dateComponents([.hour, .minute, .second], from: date) + + let hours = components.hour ?? 0 + let minutes = components.minute ?? 0 + let seconds = components.second ?? 0 + let secondsSinceMidnight = Double(hours * 3600 + minutes * 60 + seconds) + + let sortedProfile = profile.sorted { $0.timeAsSeconds < $1.timeAsSeconds } + + for i in 0 ..< sortedProfile.count { + let current = sortedProfile[i] + let nextTime: Double + if i < sortedProfile.count - 1 { + nextTime = sortedProfile[i + 1].timeAsSeconds + } else { + nextTime = 24 * 60 * 60 + } + + if secondsSinceMidnight >= current.timeAsSeconds && secondsSinceMidnight < nextTime { + return current.value + } + } + + return sortedProfile.first?.value ?? 0.0 + } + + private func calculateActualDaysCovered(dates: [TimeInterval], requestedDays: Int) -> Int { + guard !dates.isEmpty else { return requestedDays } + + let calendar = Calendar.current + let cutoffTime = Date().timeIntervalSince1970 - (Double(requestedDays) * 24 * 60 * 60) + let filteredDates = dates.filter { $0 >= cutoffTime } + + var uniqueDays = Set() + for date in filteredDates { + let dateObj = Date(timeIntervalSince1970: date) + let dayStart = calendar.startOfDay(for: dateObj) + uniqueDays.insert(dayStart) + } + + return min(uniqueDays.count, requestedDays) + } + + private func calculateProgrammedBasalFromProfile(basalProfile: [MainViewController.basalProfileStruct]) -> Double { + guard !basalProfile.isEmpty else { return 0.0 } + + let sortedProfile = basalProfile.sorted { $0.timeAsSeconds < $1.timeAsSeconds } + + var totalBasal = 0.0 + let secondsInDay = 24 * 60 * 60 + + for i in 0 ..< sortedProfile.count { + let current = sortedProfile[i] + let currentTime = Double(current.timeAsSeconds) + + let nextTime: Double + if i < sortedProfile.count - 1 { + nextTime = Double(sortedProfile[i + 1].timeAsSeconds) + } else { + nextTime = Double(secondsInDay) + } + + let durationHours = (nextTime - currentTime) / 3600.0 + totalBasal += current.value * durationHours + } + + return totalBasal + } +} diff --git a/LoopFollow/Stats/StatsDataFetcher.swift b/LoopFollow/Stats/StatsDataFetcher.swift new file mode 100644 index 000000000..fe47bd44f --- /dev/null +++ b/LoopFollow/Stats/StatsDataFetcher.swift @@ -0,0 +1,399 @@ +// LoopFollow +// StatsDataFetcher.swift + +import Foundation + +class StatsDataFetcher { + weak var mainViewController: MainViewController? + + init(mainViewController: MainViewController?) { + self.mainViewController = mainViewController + } + + func fetchBGData(days: Int, completion: @escaping () -> Void) { + guard let mainVC = mainViewController, IsNightscoutEnabled() else { + completion() + return + } + + var parameters: [String: String] = [:] + let utcISODateFormatter = ISO8601DateFormatter() + let date = Calendar.current.date(byAdding: .day, value: -1 * days, to: Date())! + parameters["count"] = "\(days * 2 * 24 * 60 / 5)" + parameters["find[dateString][$gte]"] = utcISODateFormatter.string(from: date) + parameters["find[type][$ne]"] = "cal" + + NightscoutUtils.executeRequest(eventType: .sgv, parameters: parameters) { (result: Result<[ShareGlucoseData], Error>) in + switch result { + case let .success(entriesResponse): + var nsData = entriesResponse + DispatchQueue.main.async { + // Transform NS data + for i in 0 ..< nsData.count { + nsData[i].date /= 1000 + nsData[i].date.round(FloatingPointRoundingRule.toNearestOrEven) + } + + var nsData2: [ShareGlucoseData] = [] + var lastAddedTime = Double.infinity + var lastAddedSGV: Int? + let minInterval: Double = 30 + + for reading in nsData { + if (lastAddedSGV == nil || lastAddedSGV != reading.sgv) || (lastAddedTime - reading.date >= minInterval) { + nsData2.append(reading) + lastAddedTime = reading.date + lastAddedSGV = reading.sgv + } + } + + let cutoffTime = Date().timeIntervalSince1970 - (Double(days) * 24 * 60 * 60) + mainVC.statsBGData.removeAll { $0.date < cutoffTime } + + let existingDates = Set(mainVC.statsBGData.map { Int($0.date) }) + for reading in nsData2 { + if !existingDates.contains(Int(reading.date)), reading.date >= cutoffTime { + mainVC.statsBGData.append(reading) + } + } + + mainVC.statsBGData.sort { $0.date < $1.date } + + completion() + } + case let .failure(error): + LogManager.shared.log(category: .nightscout, message: "Failed to fetch stats BG data: \(error)", limitIdentifier: "Failed to fetch stats BG data") + DispatchQueue.main.async { + completion() + } + } + } + } + + func fetchTreatmentsData(days: Int, completion: @escaping () -> Void) { + guard let mainVC = mainViewController, IsNightscoutEnabled(), Storage.shared.downloadTreatments.value else { + completion() + return + } + + let utcISODateFormatter = ISO8601DateFormatter() + utcISODateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + utcISODateFormatter.timeZone = TimeZone(abbreviation: "UTC") + + let startDate = Calendar.current.date(byAdding: .day, value: -1 * days, to: Date())! + let endDate = Date() + + let startTimeString = utcISODateFormatter.string(from: startDate) + let currentTimeString = utcISODateFormatter.string(from: endDate) + + let estimatedCount = max(days * 100, 5000) + let parameters: [String: String] = [ + "find[created_at][$gte]": startTimeString, + "find[created_at][$lte]": currentTimeString, + "count": "\(estimatedCount)", + ] + + NightscoutUtils.executeDynamicRequest(eventType: .treatments, parameters: parameters) { (result: Result) in + switch result { + case let .success(data): + if let entries = data as? [[String: AnyObject]] { + DispatchQueue.main.async { + self.fetchAndMergeBolusData(entries: entries, days: days, mainVC: mainVC) + self.fetchAndMergeSMBData(entries: entries, days: days, mainVC: mainVC) + self.fetchAndMergeCarbData(entries: entries, days: days, mainVC: mainVC) + self.fetchAndMergeBasalData(entries: entries, days: days, mainVC: mainVC) + completion() + } + } else { + DispatchQueue.main.async { + completion() + } + } + case let .failure(error): + LogManager.shared.log(category: .nightscout, message: "Failed to fetch stats treatments data: \(error.localizedDescription)") + DispatchQueue.main.async { + completion() + } + } + } + } + + private func fetchAndMergeBolusData(entries: [[String: AnyObject]], days: Int, mainVC: MainViewController) { + let cutoffTime = Date().timeIntervalSince1970 - (Double(days) * 24 * 60 * 60) + + var bolusEntries: [[String: AnyObject]] = [] + for entry in entries { + guard let eventType = entry["eventType"] as? String else { continue } + if eventType == "Correction Bolus" || eventType == "Bolus" || eventType == "External Insulin" { + if let automatic = entry["automatic"] as? Bool, automatic { + continue + } + bolusEntries.append(entry) + } else if eventType == "Meal Bolus" { + bolusEntries.append(entry) + } + } + + mainVC.statsBolusData.removeAll { $0.date < cutoffTime } + + let existingDates = Set(mainVC.statsBolusData.map { Int($0.date) }) + var lastFoundIndex = 0 + + for currentEntry in bolusEntries.reversed() { + var bolusDate: String + if currentEntry["timestamp"] != nil { + bolusDate = currentEntry["timestamp"] as! String + } else if currentEntry["created_at"] != nil { + bolusDate = currentEntry["created_at"] as! String + } else { + continue + } + + guard let parsedDate = NightscoutUtils.parseDate(bolusDate), + let bolus = currentEntry["insulin"] as? Double else { continue } + + let dateTimeStamp = parsedDate.timeIntervalSince1970 + if dateTimeStamp < cutoffTime { continue } + + // Avoid duplicates (use Int to handle floating point precision) + if existingDates.contains(Int(dateTimeStamp)) { continue } + + let sgv = mainVC.findNearestBGbyTime(needle: dateTimeStamp, haystack: mainVC.statsBGData, startingIndex: lastFoundIndex) + lastFoundIndex = sgv.foundIndex + + let dot = MainViewController.bolusGraphStruct(value: bolus, date: Double(dateTimeStamp), sgv: Int(sgv.sgv + 20)) + mainVC.statsBolusData.append(dot) + } + + mainVC.statsBolusData.sort { $0.date < $1.date } + } + + private func fetchAndMergeSMBData(entries: [[String: AnyObject]], days: Int, mainVC: MainViewController) { + let cutoffTime = Date().timeIntervalSince1970 - (Double(days) * 24 * 60 * 60) + + var smbEntries: [[String: AnyObject]] = [] + for entry in entries { + guard let eventType = entry["eventType"] as? String else { continue } + if eventType == "SMB" { + smbEntries.append(entry) + } else if eventType == "Correction Bolus" || eventType == "Bolus" || eventType == "External Insulin" { + if let automatic = entry["automatic"] as? Bool, automatic { + smbEntries.append(entry) + } + } + } + + mainVC.statsSMBData.removeAll { $0.date < cutoffTime } + + let existingDates = Set(mainVC.statsSMBData.map { Int($0.date) }) + var lastFoundIndex = 0 + + for currentEntry in smbEntries.reversed() { + var bolusDate: String + if currentEntry["timestamp"] != nil { + bolusDate = currentEntry["timestamp"] as! String + } else if currentEntry["created_at"] != nil { + bolusDate = currentEntry["created_at"] as! String + } else { + continue + } + + guard let parsedDate = NightscoutUtils.parseDate(bolusDate), + let bolus = currentEntry["insulin"] as? Double else { continue } + + let dateTimeStamp = parsedDate.timeIntervalSince1970 + if dateTimeStamp < cutoffTime { continue } + + if existingDates.contains(Int(dateTimeStamp)) { continue } + + let sgv = mainVC.findNearestBGbyTime(needle: dateTimeStamp, haystack: mainVC.statsBGData, startingIndex: lastFoundIndex) + lastFoundIndex = sgv.foundIndex + + let dot = MainViewController.bolusGraphStruct(value: bolus, date: Double(dateTimeStamp), sgv: Int(sgv.sgv + 20)) + mainVC.statsSMBData.append(dot) + } + + mainVC.statsSMBData.sort { $0.date < $1.date } + } + + private func fetchAndMergeCarbData(entries: [[String: AnyObject]], days: Int, mainVC: MainViewController) { + let cutoffTime = Date().timeIntervalSince1970 - (Double(days) * 24 * 60 * 60) + let now = Date().timeIntervalSince1970 + + var carbEntries: [[String: AnyObject]] = [] + for entry in entries { + guard let eventType = entry["eventType"] as? String else { continue } + if eventType == "Carb Correction" || eventType == "Meal Bolus" { + carbEntries.append(entry) + } + } + + mainVC.statsCarbData.removeAll { $0.date < cutoffTime || $0.date > now } + + let existingDates = Set(mainVC.statsCarbData.map { Int($0.date) }) + var lastFoundIndex = 0 + var lastFoundBolus = 0 + + for currentEntry in carbEntries.reversed() { + var carbDate: String + if currentEntry["timestamp"] != nil { + carbDate = currentEntry["timestamp"] as! String + } else if currentEntry["created_at"] != nil { + carbDate = currentEntry["created_at"] as! String + } else { + continue + } + + let absorptionTime = currentEntry["absorptionTime"] as? Int ?? 0 + + guard let parsedDate = NightscoutUtils.parseDate(carbDate), + let carbs = currentEntry["carbs"] as? Double else { continue } + + let dateTimeStamp = parsedDate.timeIntervalSince1970 + + if dateTimeStamp < cutoffTime || dateTimeStamp > now { continue } + + if existingDates.contains(Int(dateTimeStamp)) { continue } + + let sgv = mainVC.findNearestBGbyTime(needle: dateTimeStamp, haystack: mainVC.statsBGData, startingIndex: lastFoundIndex) + lastFoundIndex = sgv.foundIndex + + var offset = -50 + if sgv.sgv < Double(mainVC.calculateMaxBgGraphValue() - 100) { + let bolusTime = mainVC.findNearestBolusbyTime(timeWithin: 300, needle: dateTimeStamp, haystack: mainVC.statsBolusData, startingIndex: lastFoundBolus) + lastFoundBolus = bolusTime.foundIndex + offset = bolusTime.offset ? 70 : 20 + } + + let dot = MainViewController.carbGraphStruct(value: Double(carbs), date: Double(dateTimeStamp), sgv: Int(sgv.sgv + Double(offset)), absorptionTime: absorptionTime) + mainVC.statsCarbData.append(dot) + } + + mainVC.statsCarbData.sort { $0.date < $1.date } + } + + private func fetchAndMergeBasalData(entries: [[String: AnyObject]], days: Int, mainVC: MainViewController) { + let cutoffTime = Date().timeIntervalSince1970 - (Double(days) * 24 * 60 * 60) + + var basalEntries: [[String: AnyObject]] = [] + for entry in entries { + guard let eventType = entry["eventType"] as? String else { continue } + if eventType == "Temp Basal" { + basalEntries.append(entry) + } + } + + mainVC.statsBasalData.removeAll { $0.date < cutoffTime } + + let existingDates = Set(mainVC.statsBasalData.map { Int($0.date) }) + var tempArray = basalEntries + tempArray.reverse() + + for i in 0 ..< tempArray.count { + guard let currentEntry = tempArray[i] as [String: AnyObject]? else { continue } + + let dateString = currentEntry["timestamp"] as? String ?? currentEntry["created_at"] as? String + guard let rawDateStr = dateString, + let dateParsed = NightscoutUtils.parseDate(rawDateStr) + else { + continue + } + + let dateTimeStamp = dateParsed.timeIntervalSince1970 + if dateTimeStamp < cutoffTime { continue } + + guard let basalRate = currentEntry["absolute"] as? Double else { + continue + } + + let duration = currentEntry["duration"] as? Double ?? 0.0 + + if i > 0 { + let priorEntry = tempArray[i - 1] as [String: AnyObject]? + let priorDateStr = priorEntry?["timestamp"] as? String ?? priorEntry?["created_at"] as? String + if let rawPrior = priorDateStr, + let priorDateParsed = NightscoutUtils.parseDate(rawPrior) + { + let priorDateTimeStamp = priorDateParsed.timeIntervalSince1970 + let priorDuration = priorEntry?["duration"] as? Double ?? 0.0 + + if (dateTimeStamp - priorDateTimeStamp) > (priorDuration * 60) + 15 { + var scheduled = 0.0 + var midGap = false + var midGapTime: TimeInterval = 0 + var midGapValue: Double = 0 + + for b in 0 ..< mainVC.basalScheduleData.count { + let priorEnd = priorDateTimeStamp + (priorDuration * 60) + if priorEnd >= mainVC.basalScheduleData[b].date { + scheduled = mainVC.basalScheduleData[b].basalRate + if b < mainVC.basalScheduleData.count - 1 { + if dateTimeStamp > mainVC.basalScheduleData[b + 1].date { + midGap = true + midGapTime = mainVC.basalScheduleData[b + 1].date + midGapValue = mainVC.basalScheduleData[b + 1].basalRate + } + } + } + } + + let startDot = MainViewController.basalGraphStruct(basalRate: scheduled, date: priorDateTimeStamp + (priorDuration * 60)) + if !existingDates.contains(Int(startDot.date)) { + mainVC.statsBasalData.append(startDot) + } + + if midGap { + let endDot1 = MainViewController.basalGraphStruct(basalRate: scheduled, date: midGapTime) + if !existingDates.contains(Int(endDot1.date)) { + mainVC.statsBasalData.append(endDot1) + } + let startDot2 = MainViewController.basalGraphStruct(basalRate: midGapValue, date: midGapTime) + if !existingDates.contains(Int(startDot2.date)) { + mainVC.statsBasalData.append(startDot2) + } + let endDot2 = MainViewController.basalGraphStruct(basalRate: midGapValue, date: dateTimeStamp) + if !existingDates.contains(Int(endDot2.date)) { + mainVC.statsBasalData.append(endDot2) + } + } else { + let endDot = MainViewController.basalGraphStruct(basalRate: scheduled, date: dateTimeStamp) + if !existingDates.contains(Int(endDot.date)) { + mainVC.statsBasalData.append(endDot) + } + } + } + } + } + + let startDot = MainViewController.basalGraphStruct(basalRate: basalRate, date: dateTimeStamp) + if !existingDates.contains(Int(startDot.date)) { + mainVC.statsBasalData.append(startDot) + } + + var lastDot = dateTimeStamp + (duration * 60) + if i == tempArray.count - 1, duration == 0.0 { + lastDot = dateTimeStamp + (30 * 60) + } + + if i < tempArray.count - 1 { + let nextEntry = tempArray[i + 1] as [String: AnyObject]? + let nextDateStr = nextEntry?["timestamp"] as? String ?? nextEntry?["created_at"] as? String + if let rawNext = nextDateStr, + let nextDateParsed = NightscoutUtils.parseDate(rawNext) + { + let nextDateTimeStamp = nextDateParsed.timeIntervalSince1970 + if nextDateTimeStamp < (dateTimeStamp + (duration * 60)) { + lastDot = nextDateTimeStamp + } + } + } + + let endDot = MainViewController.basalGraphStruct(basalRate: basalRate, date: lastDot) + if !existingDates.contains(Int(endDot.date)) { + mainVC.statsBasalData.append(endDot) + } + } + + mainVC.statsBasalData.sort { $0.date < $1.date } + } +} diff --git a/LoopFollow/Stats/StatsDataService.swift b/LoopFollow/Stats/StatsDataService.swift new file mode 100644 index 000000000..90aff0c71 --- /dev/null +++ b/LoopFollow/Stats/StatsDataService.swift @@ -0,0 +1,108 @@ +// LoopFollow +// StatsDataService.swift + +import Foundation + +class StatsDataService { + weak var mainViewController: MainViewController? + + var daysToAnalyze: Int = 14 + private let dataFetcher: StatsDataFetcher + + init(mainViewController: MainViewController?) { + self.mainViewController = mainViewController + dataFetcher = StatsDataFetcher(mainViewController: mainViewController) + } + + func ensureDataAvailable(onProgress: @escaping () -> Void, completion: @escaping () -> Void) { + guard let mainVC = mainViewController else { + completion() + return + } + + let cutoffTime = Date().timeIntervalSince1970 - (Double(daysToAnalyze) * 24 * 60 * 60) + let now = Date().timeIntervalSince1970 + + let oldestBG = mainVC.statsBGData.filter { $0.date >= cutoffTime && $0.date <= now }.min(by: { $0.date < $1.date })?.date + let oldestBolus = mainVC.statsBolusData.filter { $0.date >= cutoffTime && $0.date <= now }.min(by: { $0.date < $1.date })?.date + let oldestCarb = mainVC.statsCarbData.filter { $0.date >= cutoffTime && $0.date <= now }.min(by: { $0.date < $1.date })?.date + let oldestBasal = mainVC.statsBasalData.filter { $0.date >= cutoffTime && $0.date <= now }.min(by: { $0.date < $1.date })?.date + + let bgDataCount = mainVC.statsBGData.filter { $0.date >= cutoffTime && $0.date <= now }.count + let bolusDataCount = mainVC.statsBolusData.filter { $0.date >= cutoffTime && $0.date <= now }.count + let carbDataCount = mainVC.statsCarbData.filter { $0.date >= cutoffTime && $0.date <= now }.count + let basalDataCount = mainVC.statsBasalData.filter { $0.date >= cutoffTime && $0.date <= now }.count + + let minExpectedBGEntries = max(daysToAnalyze * 6, 12) + let hasEnoughBGData = bgDataCount >= minExpectedBGEntries && (oldestBG ?? now) <= cutoffTime + (24 * 60 * 60) + let minExpectedTreatmentEntries = max(daysToAnalyze, 1) + let hasEnoughTreatmentData = (bolusDataCount + carbDataCount + basalDataCount) >= minExpectedTreatmentEntries && + (oldestBolus ?? now) <= cutoffTime + (24 * 60 * 60) && + (oldestCarb ?? now) <= cutoffTime + (24 * 60 * 60) && + (oldestBasal ?? now) <= cutoffTime + (24 * 60 * 60) + + if !hasEnoughBGData { + dataFetcher.fetchBGData(days: daysToAnalyze) { + DispatchQueue.main.async { + onProgress() + } + + if !hasEnoughTreatmentData { + self.dataFetcher.fetchTreatmentsData(days: self.daysToAnalyze) { + DispatchQueue.main.async { + onProgress() + completion() + } + } + } else { + completion() + } + } + } else if !hasEnoughTreatmentData { + dataFetcher.fetchTreatmentsData(days: daysToAnalyze) { + DispatchQueue.main.async { + onProgress() + completion() + } + } + } else { + completion() + } + } + + func getBGData() -> [ShareGlucoseData] { + guard let mainVC = mainViewController else { return [] } + let cutoffTime = Date().timeIntervalSince1970 - (Double(daysToAnalyze) * 24 * 60 * 60) + return mainVC.statsBGData.filter { $0.date >= cutoffTime } + } + + func getBolusData() -> [MainViewController.bolusGraphStruct] { + guard let mainVC = mainViewController else { return [] } + let cutoffTime = Date().timeIntervalSince1970 - (Double(daysToAnalyze) * 24 * 60 * 60) + return mainVC.statsBolusData.filter { $0.date >= cutoffTime } + } + + func getSMBData() -> [MainViewController.bolusGraphStruct] { + guard let mainVC = mainViewController else { return [] } + let cutoffTime = Date().timeIntervalSince1970 - (Double(daysToAnalyze) * 24 * 60 * 60) + return mainVC.statsSMBData.filter { $0.date >= cutoffTime } + } + + func getCarbData() -> [MainViewController.carbGraphStruct] { + guard let mainVC = mainViewController else { return [] } + let cutoffTime = Date().timeIntervalSince1970 - (Double(daysToAnalyze) * 24 * 60 * 60) + let now = Date().timeIntervalSince1970 + return mainVC.statsCarbData.filter { $0.date >= cutoffTime && $0.date <= now } + } + + func getBasalData() -> [MainViewController.basalGraphStruct] { + guard let mainVC = mainViewController else { return [] } + let cutoffTime = Date().timeIntervalSince1970 - (Double(daysToAnalyze) * 24 * 60 * 60) + return mainVC.statsBasalData.filter { $0.date >= cutoffTime } + } + + func getBasalProfile() -> [MainViewController.basalProfileStruct] { + guard let mainVC = mainViewController else { return [] } + return mainVC.basalProfile + } +} diff --git a/LoopFollow/Stats/TIR/TIRCalculator.swift b/LoopFollow/Stats/TIR/TIRCalculator.swift new file mode 100644 index 000000000..005750f44 --- /dev/null +++ b/LoopFollow/Stats/TIR/TIRCalculator.swift @@ -0,0 +1,132 @@ +// LoopFollow +// TIRCalculator.swift + +import Foundation + +class TIRCalculator { + static func calculate(bgData: [ShareGlucoseData], useTightRange: Bool = false) -> [TIRDataPoint] { + guard !bgData.isEmpty else { return [] } + + let veryLowThreshold = 54.0 + let lowThreshold = 70.0 + let highThreshold = useTightRange ? 140.0 : 180.0 + let veryHighThreshold = 250.0 + var periodData: [TIRPeriod: [Double]] = [:] + let calendar = Calendar.current + + for reading in bgData { + let date = Date(timeIntervalSince1970: reading.date) + let components = calendar.dateComponents([.hour], from: date) + let hour = components.hour ?? 0 + + let glucose = Double(reading.sgv) + + var period: TIRPeriod? + if let hourRange = TIRPeriod.night.hourRange, hour >= hourRange.start, hour < hourRange.end { + period = .night + } else if let hourRange = TIRPeriod.morning.hourRange, hour >= hourRange.start, hour < hourRange.end { + period = .morning + } else if let hourRange = TIRPeriod.day.hourRange, hour >= hourRange.start, hour < hourRange.end { + period = .day + } else if let hourRange = TIRPeriod.evening.hourRange, hour >= hourRange.start, hour < hourRange.end { + period = .evening + } + + if let period = period { + if periodData[period] == nil { + periodData[period] = [] + } + periodData[period]?.append(glucose) + } + } + + var tirPoints: [TIRDataPoint] = [] + + for period in [TIRPeriod.night, .morning, .day, .evening] { + guard let readings = periodData[period], !readings.isEmpty else { + tirPoints.append(TIRDataPoint( + period: period, + veryLow: 0.0, + low: 0.0, + inRange: 0.0, + high: 0.0, + veryHigh: 0.0 + )) + continue + } + + let percentages = calculatePercentages(readings: readings, + veryLowThreshold: veryLowThreshold, + lowThreshold: lowThreshold, + highThreshold: highThreshold, + veryHighThreshold: veryHighThreshold) + + tirPoints.append(TIRDataPoint( + period: period, + veryLow: percentages.veryLow, + low: percentages.low, + inRange: percentages.inRange, + high: percentages.high, + veryHigh: percentages.veryHigh + )) + } + + let allReadings = bgData.map { Double($0.sgv) } + let averagePercentages = calculatePercentages(readings: allReadings, + veryLowThreshold: veryLowThreshold, + lowThreshold: lowThreshold, + highThreshold: highThreshold, + veryHighThreshold: veryHighThreshold) + + tirPoints.append(TIRDataPoint( + period: .average, + veryLow: averagePercentages.veryLow, + low: averagePercentages.low, + inRange: averagePercentages.inRange, + high: averagePercentages.high, + veryHigh: averagePercentages.veryHigh + )) + + return tirPoints + } + + private static func calculatePercentages(readings: [Double], + veryLowThreshold: Double, + lowThreshold: Double, + highThreshold: Double, + veryHighThreshold: Double) -> (veryLow: Double, low: Double, inRange: Double, high: Double, veryHigh: Double) + { + let total = Double(readings.count) + guard total > 0 else { + return (0.0, 0.0, 0.0, 0.0, 0.0) + } + + var veryLowCount = 0 + var lowCount = 0 + var inRangeCount = 0 + var highCount = 0 + var veryHighCount = 0 + + for glucose in readings { + if glucose < veryLowThreshold { + veryLowCount += 1 + } else if glucose < lowThreshold { + lowCount += 1 + } else if glucose > veryHighThreshold { + veryHighCount += 1 + } else if glucose > highThreshold { + highCount += 1 + } else { + inRangeCount += 1 + } + } + + return ( + veryLow: (Double(veryLowCount) / total) * 100.0, + low: (Double(lowCount) / total) * 100.0, + inRange: (Double(inRangeCount) / total) * 100.0, + high: (Double(highCount) / total) * 100.0, + veryHigh: (Double(veryHighCount) / total) * 100.0 + ) + } +} diff --git a/LoopFollow/Stats/TIR/TIRDataPoint.swift b/LoopFollow/Stats/TIR/TIRDataPoint.swift new file mode 100644 index 000000000..0bea1b779 --- /dev/null +++ b/LoopFollow/Stats/TIR/TIRDataPoint.swift @@ -0,0 +1,36 @@ +// LoopFollow +// TIRDataPoint.swift + +import Foundation + +struct TIRDataPoint { + let period: TIRPeriod + let veryLow: Double + let low: Double + let inRange: Double + let high: Double + let veryHigh: Double +} + +enum TIRPeriod: String, CaseIterable { + case night = "Night" + case morning = "Morning" + case day = "Day" + case evening = "Evening" + case average = "Average" + + var hourRange: (start: Int, end: Int)? { + switch self { + case .night: + return (0, 6) + case .morning: + return (6, 12) + case .day: + return (12, 18) + case .evening: + return (18, 24) + case .average: + return nil + } + } +} diff --git a/LoopFollow/Stats/TIR/TIRGraphView.swift b/LoopFollow/Stats/TIR/TIRGraphView.swift new file mode 100644 index 000000000..5a509c44b --- /dev/null +++ b/LoopFollow/Stats/TIR/TIRGraphView.swift @@ -0,0 +1,101 @@ +// LoopFollow +// TIRGraphView.swift + +import Charts +import SwiftUI +import UIKit + +struct TIRGraphView: UIViewRepresentable { + let tirData: [TIRDataPoint] + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context _: Context) -> UIView { + let containerView = NonInteractiveContainerView() + containerView.backgroundColor = .systemBackground + + let chartView = BarChartView() + chartView.backgroundColor = .systemBackground + chartView.rightAxis.enabled = false + chartView.leftAxis.enabled = true + chartView.xAxis.labelPosition = .bottom + chartView.xAxis.granularity = 1.0 + chartView.leftAxis.axisMinimum = 0.0 + chartView.leftAxis.axisMaximum = 100.0 + chartView.leftAxis.valueFormatter = PercentageAxisValueFormatter() + chartView.leftAxis.labelCount = 5 + chartView.rightAxis.drawGridLinesEnabled = false + chartView.leftAxis.drawGridLinesEnabled = true + chartView.leftAxis.gridLineDashLengths = [5, 5] + chartView.xAxis.drawGridLinesEnabled = false + chartView.legend.enabled = false + chartView.chartDescription.enabled = false + chartView.isUserInteractionEnabled = false + + containerView.addSubview(chartView) + chartView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + chartView.topAnchor.constraint(equalTo: containerView.topAnchor), + chartView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + chartView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + chartView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + ]) + + return containerView + } + + class Coordinator {} + + func updateUIView(_ containerView: UIView, context _: Context) { + guard let chartView = containerView.subviews.first as? BarChartView else { return } + guard !tirData.isEmpty else { return } + + var dataEntries: [BarChartDataEntry] = [] + var xAxisLabels: [String] = [] + + for (index, point) in tirData.enumerated() { + let entry = BarChartDataEntry( + x: Double(index), + yValues: [ + point.veryLow, + point.low, + point.inRange, + point.high, + point.veryHigh, + ] + ) + dataEntries.append(entry) + xAxisLabels.append(point.period.rawValue) + } + + let dataSet = BarChartDataSet(entries: dataEntries, label: "Time in Range") + dataSet.colors = [ + UIColor.systemRed.withAlphaComponent(0.8), + UIColor.systemRed.withAlphaComponent(0.5), + UIColor.systemGreen.withAlphaComponent(0.7), + UIColor.systemYellow.withAlphaComponent(0.7), + UIColor.systemOrange.withAlphaComponent(0.7), + ] + dataSet.stackLabels = ["Very Low", "Low", "In Range", "High", "Very High"] + dataSet.drawValuesEnabled = false + + let data = BarChartData(dataSet: dataSet) + data.barWidth = 0.6 + + chartView.data = data + + chartView.xAxis.valueFormatter = IndexAxisValueFormatter(values: xAxisLabels) + chartView.xAxis.labelRotationAngle = 0 + chartView.xAxis.labelCount = xAxisLabels.count + + chartView.notifyDataSetChanged() + } +} + +class PercentageAxisValueFormatter: AxisValueFormatter { + func stringForValue(_ value: Double, axis _: AxisBase?) -> String { + return String(format: "%.0f%%", value) + } +} diff --git a/LoopFollow/Stats/TIR/TIRView.swift b/LoopFollow/Stats/TIR/TIRView.swift new file mode 100644 index 000000000..e74c34b41 --- /dev/null +++ b/LoopFollow/Stats/TIR/TIRView.swift @@ -0,0 +1,117 @@ +// LoopFollow +// TIRView.swift + +import SwiftUI + +struct TIRView: View { + @ObservedObject var viewModel: TIRViewModel + + var body: some View { + Button(action: { + viewModel.toggleTIRMode() + }) { + ZStack(alignment: .topTrailing) { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(viewModel.showTITR ? "Time in Tight Range" : "Time in Range") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + if let inRangeValue = viewModel.tirData.first(where: { $0.period == .average })?.inRange { + Text(formatRange(inRangeValue)) + .font(.caption) + .foregroundColor(.secondary) + } + } + + if !viewModel.tirData.isEmpty { + TIRGraphView(tirData: viewModel.tirData) + .frame(height: 250) + .allowsHitTesting(false) + .clipped() + + VStack(alignment: .leading, spacing: 8) { + if let average = viewModel.tirData.first(where: { $0.period == .average }) { + TIRLegendItem( + color: .orange, + label: "Very High", + percentage: average.veryHigh + ) + TIRLegendItem( + color: .yellow, + label: "High", + percentage: average.high + ) + TIRLegendItem( + color: .green, + label: "In Range", + percentage: average.inRange + ) + TIRLegendItem( + color: .red.opacity(0.5), + label: "Low", + percentage: average.low + ) + TIRLegendItem( + color: .red.opacity(0.8), + label: "Very Low", + percentage: average.veryLow + ) + } + } + .font(.caption2) + } else { + Text("No data available") + .font(.caption) + .foregroundColor(.secondary) + .frame(height: 250) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + + Image(systemName: "chevron.up.chevron.down") + .font(.caption2) + .foregroundColor(.secondary.opacity(0.5)) + .padding(8) + } + .background(Color(.systemGray6)) + .cornerRadius(12) + } + .buttonStyle(PlainButtonStyle()) + } + + private func formatRange(_: Double) -> String { + let lowThreshold: Double + let highThreshold: Double + + if Storage.shared.units.value == "mg/dL" { + lowThreshold = 70.0 + highThreshold = viewModel.showTITR ? 140.0 : 180.0 + } else { + lowThreshold = 3.9 + highThreshold = viewModel.showTITR ? 7.8 : 10.0 + } + + return String(format: "%.1f – %.1f %@", lowThreshold, highThreshold, Storage.shared.units.value) + } +} + +struct TIRLegendItem: View { + let color: Color + let label: String + let percentage: Double + + var body: some View { + HStack(spacing: 8) { + Rectangle() + .fill(color) + .frame(width: 16, height: 16) + Text(String(format: "%.1f%%", percentage)) + .foregroundColor(.primary) + Text(label) + .foregroundColor(.secondary) + Spacer() + } + } +} diff --git a/LoopFollow/Stats/TIR/TIRViewModel.swift b/LoopFollow/Stats/TIR/TIRViewModel.swift new file mode 100644 index 000000000..6a4ccd451 --- /dev/null +++ b/LoopFollow/Stats/TIR/TIRViewModel.swift @@ -0,0 +1,29 @@ +// LoopFollow +// TIRViewModel.swift + +import Combine +import Foundation + +class TIRViewModel: ObservableObject { + @Published var tirData: [TIRDataPoint] = [] + @Published var showTITR: Bool + + private let dataService: StatsDataService + + init(dataService: StatsDataService) { + self.dataService = dataService + showTITR = Storage.shared.showTITR.value + calculateTIR() + } + + func calculateTIR() { + let bgData = dataService.getBGData() + tirData = TIRCalculator.calculate(bgData: bgData, useTightRange: showTITR) + } + + func toggleTIRMode() { + showTITR.toggle() + Storage.shared.showTITR.value = showTITR + calculateTIR() + } +} diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index cdf5abf31..ff2f323f0 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -171,6 +171,11 @@ class Storage { var returnApnsKey = StorageValue(key: "returnApnsKey", defaultValue: "") var returnKeyId = StorageValue(key: "returnKeyId", defaultValue: "") + // Statistics display preferences + var showGMI = StorageValue(key: "showGMI", defaultValue: true) + var showStdDev = StorageValue(key: "showStdDev", defaultValue: true) + var showTITR = StorageValue(key: "showTITR", defaultValue: false) + static let shared = Storage() private init() {} } diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift new file mode 100644 index 000000000..f0443cbed --- /dev/null +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -0,0 +1,1031 @@ +// LoopFollow +// TreatmentsView.swift + +import SwiftUI + +struct TreatmentsView: View { + @StateObject private var viewModel = TreatmentsViewModel() + + var body: some View { + List { + if viewModel.isInitialLoading { + HStack { + Spacer() + ProgressView() + .padding() + Spacer() + } + } else if viewModel.groupedTreatments.isEmpty { + Text("No recent treatments") + .foregroundColor(.secondary) + .padding() + } else { + ForEach(viewModel.groupedTreatments.keys.sorted(by: >), id: \.self) { hourKey in + Section(header: Text(formatHourHeader(hourKey))) { + ForEach(viewModel.groupedTreatments[hourKey] ?? []) { treatment in + TreatmentRow(treatment: treatment) + } + } + } + + Section { + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + .padding() + Spacer() + } + } else { + Button(action: { + viewModel.loadMoreIfNeeded() + }) { + HStack { + Spacer() + VStack { + Text("Load More") + .font(.headline) + .foregroundColor(.blue) + Text("Tap to load older treatments") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + } + .padding(.vertical, 8) + } + } + } + } + } + .navigationTitle("Treatments") + .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil) + .refreshable { + viewModel.refreshTreatments() + } + .onAppear { + if viewModel.groupedTreatments.isEmpty { + viewModel.loadInitialTreatments() + } + } + } + + private func formatHourHeader(_ hourKey: String) -> String { + let components = hourKey.split(separator: "-") + guard components.count == 4, + let year = Int(components[0]), + let month = Int(components[1]), + let day = Int(components[2]), + let hour = Int(components[3]) + else { + return hourKey + } + + var dateComponents = DateComponents() + dateComponents.year = year + dateComponents.month = month + dateComponents.day = day + dateComponents.hour = hour + + guard let date = Calendar.current.date(from: dateComponents) else { + return hourKey + } + + let timeFormatter = DateFormatter() + timeFormatter.locale = Locale.current + timeFormatter.dateStyle = .none + timeFormatter.timeStyle = .short // Respects user's 12/24-hour preference + + let timeString = timeFormatter.string(from: date) + + // Create the full header string based on the day + if Calendar.current.isDateInToday(date) { + return "Today \(timeString)" + } else if Calendar.current.isDateInYesterday(date) { + return "Yesterday \(timeString)" + } else { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale.current + dateFormatter.dateFormat = "MMM d" + let dateString = dateFormatter.string(from: date) + return "\(dateString), \(timeString)" + } + } +} + +struct TreatmentDetailView: View { + let treatment: Treatment + @StateObject private var viewModel = TreatmentDetailViewModel() + + var body: some View { + List { + // Treatment Info Section (no header) + Section { + HStack { + Image(systemName: treatment.icon) + .foregroundColor(treatment.color) + .opacity(treatment.type == .tempBasal ? 0.5 : 1.0) + .frame(width: 24) + Text(treatment.title) + .font(.headline) + if let subtitle = treatment.subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundColor(.secondary) + } + Spacer() + } + } + + // Glucose at time + if viewModel.isLoading { + Section { + HStack { + Spacer() + ProgressView() + .padding() + Spacer() + } + } + } else if let detail = viewModel.detail, detail.glucose > 0 { + Section(header: Text("Glucose value")) { + HStack { + Text(formatBG(detail.glucose)) + Spacer() + } + } + } + + if !viewModel.isLoading, let detail = viewModel.detail { + if detail.iob != nil || detail.cob != nil || detail.eventualBG != nil { + Section(header: Text("Loop Data")) { + HStack(spacing: 20) { + if let iob = detail.iob { + VStack(alignment: .leading) { + Text("IOB") + .font(.caption) + .foregroundColor(.secondary) + Text(String(format: "%.2f U", iob)) + .font(.headline) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let cob = detail.cob { + VStack(alignment: .leading) { + Text("COB") + .font(.caption) + .foregroundColor(.secondary) + Text(String(format: "%.0f g", cob)) + .font(.headline) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let eventualBG = detail.eventualBG, eventualBG > 0 { + VStack(alignment: .leading) { + Text("Eventual") + .font(.caption) + .foregroundColor(.secondary) + Text(formatBG(eventualBG)) + .font(.headline) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.vertical, 4) + } + } + + // Prediction Range + if detail.minBG > 0 || detail.maxBG > 0 { + Section(header: Text("Prediction")) { + if detail.minBG > 0 && detail.maxBG > 0 { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Min") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text(formatBG(detail.minBG)) + .font(.subheadline) + } + HStack { + Text("Max") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text(formatBG(detail.maxBG)) + .font(.subheadline) + } + } + } else if detail.minBG > 0 { + HStack { + Text("Minimum") + Spacer() + Text(formatBG(detail.minBG)) + .foregroundColor(.secondary) + } + } else if detail.maxBG > 0 { + HStack { + Text("Maximum") + Spacer() + Text(formatBG(detail.maxBG)) + .foregroundColor(.secondary) + } + } + } + } + + // Active Override + if let overrideName = detail.overrideName { + Section(header: Text("Active Override")) { + HStack { + Text(overrideName) + Spacer() + } + if let multiplier = detail.overrideMultiplier { + HStack { + Text("Sensitivity") + Spacer() + Text(String(format: "%.0f%%", multiplier * 100)) + .foregroundColor(.secondary) + } + } + if let targetRange = detail.overrideTargetRange { + HStack { + Text("Target Range") + Spacer() + Text("\(formatBG(targetRange.min)) - \(formatBG(targetRange.max))") + .foregroundColor(.secondary) + } + } + } + } + + // Recommended Bolus + if let recommendedBolus = detail.recommendedBolus, recommendedBolus > 0 { + Section(header: Text("Recommended Bolus")) { + HStack { + Text(String(format: "%.2f U", recommendedBolus)) + Spacer() + } + } + } + } + } + .navigationTitle(formatNavigationTitle(treatment.date)) + .navigationBarTitleDisplayMode(.inline) + .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil) + .onAppear { + viewModel.loadDetails(for: treatment) + } + } + + private func formatNavigationTitle(_ timeInterval: TimeInterval) -> String { + let date = Date(timeIntervalSince1970: timeInterval) + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + let fullString = formatter.string(from: date) + // Remove " at " if it exists (some locales use it) + return fullString.replacingOccurrences(of: " at ", with: " ") + } + + private func formatBG(_ mgdlValue: Int) -> String { + let units = Storage.shared.units.value + + if units == "mg/dL" { + return "\(mgdlValue) mg/dL" + } else { + let mmolValue = Double(mgdlValue) * GlucoseConversion.mgDlToMmolL + return String(format: "%.1f mmol/L", mmolValue) + } + } +} + +struct DeviceStatusData { + let timestamp: TimeInterval + let iob: Double? + let cob: Double? + let eventualBG: Int? + let minPredictedBG: Int? + let maxPredictedBG: Int? + let overrideName: String? + let overrideMultiplier: Double? + let overrideTargetRange: (min: Int, max: Int)? + let recommendedBolus: Double? +} + +struct TreatmentDetailData { + let glucose: Int + let iob: Double? + let cob: Double? + let eventualBG: Int? + let minBG: Int + let maxBG: Int + let loopStatus: String? + let overrideName: String? + let overrideMultiplier: Double? + let overrideTargetRange: (min: Int, max: Int)? + let recommendedBolus: Double? +} + +class TreatmentDetailViewModel: ObservableObject { + @Published var detail: TreatmentDetailData? + @Published var isLoading = false + + func loadDetails(for treatment: Treatment) { + guard let mainVC = getMainViewController() else { return } + + isLoading = true + + // Find closest BG reading + let glucose = findNearestBG(at: treatment.date, in: mainVC.bgData) + + // Fetch historical device status from Nightscout + fetchDeviceStatusHistory(around: treatment.date) { [weak self] deviceStatus in + guard let self = self else { return } + + DispatchQueue.main.async { + self.detail = TreatmentDetailData( + glucose: glucose, + iob: deviceStatus?.iob, + cob: deviceStatus?.cob, + eventualBG: deviceStatus?.eventualBG, + minBG: deviceStatus?.minPredictedBG ?? 0, + maxBG: deviceStatus?.maxPredictedBG ?? 0, + loopStatus: nil, + overrideName: deviceStatus?.overrideName, + overrideMultiplier: deviceStatus?.overrideMultiplier, + overrideTargetRange: deviceStatus?.overrideTargetRange, + recommendedBolus: deviceStatus?.recommendedBolus + ) + self.isLoading = false + } + } + } + + private func fetchDeviceStatusHistory(around timestamp: TimeInterval, completion: @escaping (DeviceStatusData?) -> Void) { + // Fetch device status entries around the treatment time + // We'll get a range of entries to find the closest one + let targetDate = Date(timeIntervalSince1970: timestamp) + let startDate = targetDate.addingTimeInterval(-30 * 60) // 30 minutes before + let endDate = targetDate.addingTimeInterval(30 * 60) // 30 minutes after + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + // Build parameters + let parameters: [String: String] = [ + "find[created_at][$gte]": formatter.string(from: startDate), + "find[created_at][$lte]": formatter.string(from: endDate), + "count": "30", + ] + + NightscoutUtils.executeDynamicRequest(eventType: .deviceStatus, parameters: parameters) { result in + switch result { + case let .success(json): + if let jsonDeviceStatus = json as? [[String: AnyObject]] { + // Find the entry closest to our target timestamp + let deviceStatus = self.parseClosestDeviceStatus(from: jsonDeviceStatus, targetTimestamp: timestamp) + completion(deviceStatus) + } else { + completion(nil) + } + case .failure: + completion(nil) + } + } + } + + private func parseClosestDeviceStatus(from jsonArray: [[String: AnyObject]], targetTimestamp: TimeInterval) -> DeviceStatusData? { + var closestEntry: DeviceStatusData? + var smallestDiff: TimeInterval = .infinity + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withFullDate, .withTime, .withDashSeparatorInDate, .withColonSeparatorInTime] + + for entry in jsonArray { + // Parse timestamp + var entryTimestamp: TimeInterval = 0 + + if let createdAt = entry["created_at"] as? String, + let date = formatter.date(from: createdAt) + { + entryTimestamp = date.timeIntervalSince1970 + } else if let dateString = entry["dateString"] as? String { + // Try parsing dateString as fallback + if let date = NightscoutUtils.parseDate(dateString) { + entryTimestamp = date.timeIntervalSince1970 + } + } + + guard entryTimestamp > 0 else { continue } + + let diff = abs(entryTimestamp - targetTimestamp) + if diff < smallestDiff { + smallestDiff = diff + + // Extract IOB, COB, and prediction data + var iob: Double? + var cob: Double? + var eventualBG: Int? + var minPredictedBG: Int? + var maxPredictedBG: Int? + var overrideName: String? + var overrideMultiplier: Double? + var overrideTargetRange: (min: Int, max: Int)? + var recommendedBolus: Double? + + // Try Loop format first + if let loopRecord = entry["loop"] as? [String: AnyObject] { + // IOB + if let iobDict = loopRecord["iob"] as? [String: AnyObject], + let iobValue = iobDict["iob"] as? Double + { + iob = iobValue + } + + // COB + if let cobDict = loopRecord["cob"] as? [String: AnyObject], + let cobValue = cobDict["cob"] as? Double + { + cob = cobValue + } + + // Predictions + if let predicted = loopRecord["predicted"] as? [String: AnyObject] { + if let values = predicted["values"] as? [Int], !values.isEmpty { + eventualBG = values.last + minPredictedBG = values.min() + maxPredictedBG = values.max() + } + } + + // Recommended Bolus + if let recBolus = loopRecord["recommendedBolus"] as? Double { + recommendedBolus = recBolus + } + } + + // Try OpenAPS format + if let openapsRecord = entry["openaps"] as? [String: AnyObject] { + if let suggested = openapsRecord["suggested"] as? [String: AnyObject] { + if let iobValue = suggested["IOB"] as? Double { + iob = iobValue + } + if let cobValue = suggested["COB"] as? Double { + cob = cobValue + } + if let eventualValue = suggested["eventualBG"] as? Int { + eventualBG = eventualValue + } + } + + if let enacted = openapsRecord["enacted"] as? [String: AnyObject] { + if iob == nil, let iobValue = enacted["IOB"] as? Double { + iob = iobValue + } + if cob == nil, let cobValue = enacted["COB"] as? Double { + cob = cobValue + } + } + } + + // Parse override data + if let overrideRecord = entry["override"] as? [String: AnyObject], + let isActive = overrideRecord["active"] as? Bool, + isActive + { + overrideName = overrideRecord["name"] as? String + overrideMultiplier = overrideRecord["multiplier"] as? Double + + if let currentRange = overrideRecord["currentCorrectionRange"] as? [String: AnyObject], + let minValue = currentRange["minValue"] as? Double, + let maxValue = currentRange["maxValue"] as? Double + { + overrideTargetRange = (min: Int(minValue), max: Int(maxValue)) + } + } + + closestEntry = DeviceStatusData( + timestamp: entryTimestamp, + iob: iob, + cob: cob, + eventualBG: eventualBG, + minPredictedBG: minPredictedBG, + maxPredictedBG: maxPredictedBG, + overrideName: overrideName, + overrideMultiplier: overrideMultiplier, + overrideTargetRange: overrideTargetRange, + recommendedBolus: recommendedBolus + ) + } + } + + // Only return if we found something within 15 minutes + if smallestDiff < 15 * 60 { + return closestEntry + } + + return nil + } + + private func findNearestBG(at timestamp: TimeInterval, in bgData: [ShareGlucoseData]) -> Int { + guard !bgData.isEmpty else { return 0 } + + var closestBG: ShareGlucoseData? + var smallestDiff: TimeInterval = .infinity + + for bg in bgData { + let diff = abs(bg.date - timestamp) + if diff < smallestDiff { + smallestDiff = diff + closestBG = bg + } + + if diff > smallestDiff && smallestDiff < 300 { + break + } + } + + if let bg = closestBG, smallestDiff < 600 { + return Int(bg.sgv) + } + + return 0 + } + + private func getMainViewController() -> MainViewController? { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let tabBarController = window.rootViewController as? UITabBarController + else { + return nil + } + + for vc in tabBarController.viewControllers ?? [] { + if let mainVC = vc as? MainViewController { + return mainVC + } + if let navVC = vc as? UINavigationController, + let mainVC = navVC.viewControllers.first as? MainViewController + { + return mainVC + } + } + + return nil + } +} + +struct TreatmentRow: View { + let treatment: Treatment + + var body: some View { + NavigationLink(destination: TreatmentDetailView(treatment: treatment)) { + HStack { + Image(systemName: treatment.icon) + .foregroundColor(treatment.color) + .opacity(treatment.type == .tempBasal ? 0.5 : 1.0) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(treatment.title) + .font(.headline) + if let subtitle = treatment.subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + Text(formatTime(treatment.date)) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(.vertical, 4) + } + } + + private func formatTime(_ timeInterval: TimeInterval) -> String { + let date = Date(timeIntervalSince1970: timeInterval) + let formatter = DateFormatter() + formatter.timeStyle = .short + return formatter.string(from: date) + } +} + +enum TreatmentType { + case carb + case bolus + case smb + case tempBasal +} + +struct Treatment: Identifiable { + let id: String + let type: TreatmentType + let date: TimeInterval + let title: String + let subtitle: String? + let icon: String + let color: Color + let bgValue: Int + + init(id: String? = nil, type: TreatmentType, date: TimeInterval, title: String, subtitle: String?, icon: String, color: Color, bgValue: Int) { + self.id = id ?? "\(type)-\(date)-\(title)" + self.type = type + self.date = date + self.title = title + self.subtitle = subtitle + self.icon = icon + self.color = color + self.bgValue = bgValue + } + + var hourKey: String { + let date = Date(timeIntervalSince1970: self.date) + let calendar = Calendar.current + let components = calendar.dateComponents([.year, .month, .day, .hour], from: date) + return "\(components.year!)-\(components.month!)-\(components.day!)-\(components.hour!)" + } +} + +class TreatmentsViewModel: ObservableObject { + @Published var groupedTreatments: [String: [Treatment]] = [:] + @Published var isInitialLoading = false + @Published var isLoadingMore = false + @Published var hasMoreData = true + + private var allTreatments: [Treatment] = [] + private var processedNightscoutIds = Set() // Track which NS entries we've already processed + private var oldestFetchedDate: Date? // Track the oldest treatment date we've fetched + private let pageSize = 100 + private var isFetching = false + + func loadInitialTreatments() { + guard !isInitialLoading, !isFetching else { + return + } + + isInitialLoading = true + isFetching = true + allTreatments.removeAll() + processedNightscoutIds.removeAll() + oldestFetchedDate = nil + hasMoreData = true + + // Start from now and go backwards + fetchTreatments(endDate: Date()) { [weak self] treatments, rawCount in + guard let self = self else { return } + + DispatchQueue.main.async { + self.allTreatments = treatments + self.regroupTreatments() + + // Update oldest date from the last (oldest) treatment + if let oldest = treatments.last { + self.oldestFetchedDate = Date(timeIntervalSince1970: oldest.date) + } + + // Has more data if we got a full page from Nightscout + self.hasMoreData = rawCount >= self.pageSize + self.isInitialLoading = false + self.isFetching = false + } + } + } + + func refreshTreatments() { + allTreatments.removeAll() + processedNightscoutIds.removeAll() + oldestFetchedDate = nil + hasMoreData = true + isFetching = false + loadInitialTreatments() + } + + func loadMoreIfNeeded() { + guard !isLoadingMore, !isFetching, hasMoreData, let oldestDate = oldestFetchedDate else { + return + } + + isLoadingMore = true + isFetching = true + + // Fetch treatments older than the oldest we have + fetchTreatments(endDate: oldestDate) { [weak self] treatments, rawCount in + guard let self = self else { return } + + DispatchQueue.main.async { + self.allTreatments.append(contentsOf: treatments) + self.regroupTreatments() + + // Update oldest date from the last (oldest) treatment in the new batch + if let oldest = treatments.last { + self.oldestFetchedDate = Date(timeIntervalSince1970: oldest.date) + } + + // Has more data if we got a full page from Nightscout + self.hasMoreData = rawCount >= self.pageSize + self.isLoadingMore = false + self.isFetching = false + } + } + } + + private func fetchTreatments(endDate: Date, completion: @escaping ([Treatment], Int) -> Void) { + guard IsNightscoutEnabled() else { + completion([], 0) + return + } + + let baseURL = Storage.shared.url.value + let token = Storage.shared.token.value + + guard !baseURL.isEmpty else { + completion([], 0) + return + } + + // Format dates for the query + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + formatter.timeZone = TimeZone(abbreviation: "UTC") + + // For pagination: fetch treatments with created_at < endDate + // Go back up to 365 days from endDate to ensure we get enough data + let startDate = Calendar.current.date(byAdding: .day, value: -365, to: endDate)! + let endDateString = formatter.string(from: endDate) + let startDateString = formatter.string(from: startDate) + + // Build parameters with date filtering + let parameters: [String: String] = [ + "find[created_at][$gte]": startDateString, + "find[created_at][$lt]": endDateString, + "count": "\(pageSize)", + ] + + // Construct URL + guard let url = NightscoutUtils.constructURL( + baseURL: baseURL, + token: token, + endpoint: "/api/v1/treatments.json", + parameters: parameters + ) else { + completion([], 0) + return + } + + var request = URLRequest(url: url) + request.cachePolicy = URLRequest.CachePolicy.reloadIgnoringLocalCacheData + request.httpMethod = "GET" + + let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in + guard let self = self else { return } + + if let error = error { + LogManager.shared.log(category: .nightscout, message: "Failed to fetch treatments: \(error.localizedDescription)") + completion([], 0) + return + } + + guard let data = data else { + completion([], 0) + return + } + + do { + guard let entries = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: AnyObject]] else { + completion([], 0) + return + } + + // Parse treatments + let rawCount = entries.count + let treatments = self.parseTreatments(from: entries) + + completion(treatments, rawCount) + + } catch { + LogManager.shared.log(category: .nightscout, message: "Failed to parse treatments: \(error.localizedDescription)") + completion([], 0) + } + } + + task.resume() + } + + private func parseTreatments(from entries: [[String: AnyObject]]) -> [Treatment] { + var treatments: [Treatment] = [] + guard let mainVC = getMainViewController() else { return [] } + + for entry in entries { + guard let eventType = entry["eventType"] as? String, + let createdAt = entry["created_at"] as? String, + let date = NightscoutUtils.parseDate(createdAt) + else { + continue + } + + let timestamp = date.timeIntervalSince1970 + let nsId = entry["_id"] as? String ?? "unknown-\(timestamp)" + + // Skip if we've already processed this Nightscout entry + if processedNightscoutIds.contains(nsId) { + continue + } + + // Mark this entry as processed + processedNightscoutIds.insert(nsId) + + switch eventType { + case "Carb Correction", "Meal Bolus": + if let carbs = entry["carbs"] as? Double, carbs > 0 { + let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) + let treatment = Treatment( + id: "\(nsId)-carb", + type: .carb, + date: timestamp, + title: "\(Int(carbs))g", + subtitle: "Carbs", + icon: "circle.fill", + color: .orange, + bgValue: actualBG + ) + treatments.append(treatment) + } + + // Also process bolus from Meal Bolus + if eventType == "Meal Bolus", + let insulin = entry["insulin"] as? Double, insulin > 0 + { + let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) + let treatment = Treatment( + id: "\(nsId)-bolus", + type: .bolus, + date: timestamp, + title: String(format: "%.2f U", insulin), + subtitle: "Bolus", + icon: "circle.fill", + color: .blue, + bgValue: actualBG + ) + treatments.append(treatment) + } + + case "Correction Bolus", "Bolus", "External Insulin": + if let insulin = entry["insulin"] as? Double, insulin > 0 { + let isAutomatic = entry["automatic"] as? Bool ?? false + let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) + + if isAutomatic { + let treatment = Treatment( + id: "\(nsId)-smb", + type: .smb, + date: timestamp, + title: String(format: "%.2f U", insulin), + subtitle: "Automatic Bolus", + icon: "arrowtriangle.down.fill", + color: .blue, + bgValue: actualBG + ) + treatments.append(treatment) + } else { + let treatment = Treatment( + id: "\(nsId)-bolus", + type: .bolus, + date: timestamp, + title: String(format: "%.2f U", insulin), + subtitle: "Bolus", + icon: "circle.fill", + color: .blue, + bgValue: actualBG + ) + treatments.append(treatment) + } + } + + case "SMB": + if let insulin = entry["insulin"] as? Double, insulin > 0 { + let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) + let treatment = Treatment( + id: "\(nsId)-smb", + type: .smb, + date: timestamp, + title: String(format: "%.2f U", insulin), + subtitle: "Automatic Bolus", + icon: "arrowtriangle.down.fill", + color: .blue, + bgValue: actualBG + ) + treatments.append(treatment) + } + + case "Temp Basal": + if let rate = entry["rate"] as? Double { + let treatment = Treatment( + id: "\(nsId)-basal", + type: .tempBasal, + date: timestamp, + title: String(format: "%.2f U/hr", rate), + subtitle: "Temp Basal", + icon: "chart.xyaxis.line", + color: .blue, + bgValue: 0 + ) + treatments.append(treatment) + } + + default: + break + } + } + + // Sort by date descending (most recent first) + return treatments.sorted { $0.date > $1.date } + } + + private func regroupTreatments() { + var grouped: [String: [Treatment]] = [:] + + for treatment in allTreatments { + let key = treatment.hourKey + if grouped[key] == nil { + grouped[key] = [] + } + grouped[key]?.append(treatment) + } + + // Sort treatments within each hour + for key in grouped.keys { + grouped[key]?.sort { $0.date > $1.date } + } + + groupedTreatments = grouped + } + + private func findNearestBG(at timestamp: TimeInterval, in bgData: [ShareGlucoseData]) -> Int { + // Find the closest BG reading to the treatment time + guard !bgData.isEmpty else { return 0 } + + var closestBG: ShareGlucoseData? + var smallestDiff: TimeInterval = .infinity + + for bg in bgData { + let diff = abs(bg.date - timestamp) + if diff < smallestDiff { + smallestDiff = diff + closestBG = bg + } + + // If we're getting further away, we can stop (data is sorted) + if diff > smallestDiff && smallestDiff < 300 { // Within 5 minutes + break + } + } + + // Only return BG if it's within 10 minutes of the treatment + if let bg = closestBG, smallestDiff < 600 { + return Int(bg.sgv) + } + + return 0 + } + + private func getMainViewController() -> MainViewController? { + // Try to find MainViewController in the app's window hierarchy + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let tabBarController = window.rootViewController as? UITabBarController + else { + return nil + } + + for vc in tabBarController.viewControllers ?? [] { + if let mainVC = vc as? MainViewController { + return mainVC + } + if let navVC = vc as? UINavigationController, + let mainVC = navVC.viewControllers.first as? MainViewController + { + return mainVC + } + } + + return nil + } +} + +struct TreatmentsView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + TreatmentsView() + } + } +} diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index c579c0911..f0a658c1d 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -72,6 +72,13 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele var bolusData: [bolusGraphStruct] = [] var smbData: [bolusGraphStruct] = [] var carbData: [carbGraphStruct] = [] + + // Stats-specific data storage (can hold up to 30 days) + var statsBGData: [ShareGlucoseData] = [] + var statsBolusData: [bolusGraphStruct] = [] + var statsSMBData: [bolusGraphStruct] = [] + var statsCarbData: [carbGraphStruct] = [] + var statsBasalData: [basalGraphStruct] = [] var overrideGraphData: [DataStructs.overrideStruct] = [] var tempTargetGraphData: [DataStructs.tempTargetStruct] = [] var predictionData: [ShareGlucoseData] = [] diff --git a/LoopFollow/ViewControllers/MoreMenuViewController.swift b/LoopFollow/ViewControllers/MoreMenuViewController.swift index 5c01e04c5..1d73ab445 100644 --- a/LoopFollow/ViewControllers/MoreMenuViewController.swift +++ b/LoopFollow/ViewControllers/MoreMenuViewController.swift @@ -66,6 +66,24 @@ class MoreMenuViewController: UIViewController { } )) + // Always add Treatments + menuItems.append(MenuItem( + title: "Treatments", + icon: "cross.case.fill", + action: { [weak self] in + self?.openTreatments() + } + )) + + // Always add Statistics + menuItems.append(MenuItem( + title: "Statistics", + icon: "chart.bar.fill", + action: { [weak self] in + self?.openAggregatedStats() + } + )) + // Add items based on their positions if Storage.shared.alarmsPosition.value == .more { menuItems.append(MenuItem( @@ -185,6 +203,73 @@ class MoreMenuViewController: UIViewController { present(navController, animated: true) } + private func openTreatments() { + let treatmentsVC = UIHostingController(rootView: TreatmentsView()) + let navController = UINavigationController(rootViewController: treatmentsVC) + + // Apply dark mode if needed + if Storage.shared.forceDarkMode.value { + treatmentsVC.overrideUserInterfaceStyle = .dark + navController.overrideUserInterfaceStyle = .dark + } + + // Add a close button + treatmentsVC.navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(dismissModal) + ) + + navController.modalPresentationStyle = .fullScreen + present(navController, animated: true) + } + + private func openAggregatedStats() { + guard let mainVC = getMainViewController() else { + presentSimpleAlert(title: "Error", message: "Unable to access data") + return + } + + let statsVC = UIHostingController( + rootView: AggregatedStatsView(viewModel: AggregatedStatsViewModel(mainViewController: mainVC)) + ) + let navController = UINavigationController(rootViewController: statsVC) + + // Apply dark mode if needed + if Storage.shared.forceDarkMode.value { + statsVC.overrideUserInterfaceStyle = .dark + navController.overrideUserInterfaceStyle = .dark + } + + // Add a close button + statsVC.navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(dismissModal) + ) + + navController.modalPresentationStyle = .fullScreen + present(navController, animated: true) + } + + private func getMainViewController() -> MainViewController? { + // Try to find MainViewController in the view hierarchy + guard let tabBarController = tabBarController else { return nil } + + for vc in tabBarController.viewControllers ?? [] { + if let mainVC = vc as? MainViewController { + return mainVC + } + if let navVC = vc as? UINavigationController, + let mainVC = navVC.viewControllers.first as? MainViewController + { + return mainVC + } + } + + return nil + } + @objc private func dismissModal() { dismiss(animated: true) }