From 5a752fcd6eb5245dcd4234ddcf95b2653aa97c20 Mon Sep 17 00:00:00 2001 From: greyghost99 <164137251+greyghost99@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:30:46 -0700 Subject: [PATCH 1/5] Add endo report PDF generator --- LoopFollow.xcodeproj/project.pbxproj | 16 +- LoopFollow/Stats/AggregatedStatsView.swift | 21 +- LoopFollow/Stats/EndoReportGenerator.swift | 1037 ++++++++++++++++++++ LoopFollow/Stats/EndoReportView.swift | 912 +++++++++++++++++ 4 files changed, 1973 insertions(+), 13 deletions(-) create mode 100644 LoopFollow/Stats/EndoReportGenerator.swift create mode 100644 LoopFollow/Stats/EndoReportView.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index f3363e164..c82dad6b7 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -64,6 +64,10 @@ 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; }; 65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; }; 66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */; }; + 9C7FB98C98BE4FF98F4815EE /* Telemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFBE69CEF18416D84959974 /* Telemetry.swift */; }; + A1A1A10001000000A0CFEED1 /* APNsCredentialValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */; }; + A1A1A10002000000A0CFEED1 /* LogRedactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10002000000A0CFEED2 /* LogRedactor.swift */; }; + AB1CD0012C7B30D40048F05C /* RemoteDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */; }; ACE7F6DE0D065BEB52CDC0DB /* FutureCarbsAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */; }; DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; }; DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; }; @@ -109,7 +113,6 @@ DD4878052C7B2C970048F05C /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878042C7B2C970048F05C /* Storage.swift */; }; DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */; }; DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */; }; - AB1CD0012C7B30D40048F05C /* RemoteDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */; }; DD48780E2C7B74A40048F05C /* TrioRemoteControlViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48780D2C7B74A40048F05C /* TrioRemoteControlViewModel.swift */; }; DD4878102C7B74BF0048F05C /* TrioRemoteControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48780F2C7B74BF0048F05C /* TrioRemoteControlView.swift */; }; DD4878132C7B750D0048F05C /* TempTargetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878122C7B750D0048F05C /* TempTargetView.swift */; }; @@ -251,7 +254,6 @@ DDD10F0B2C54192A00D76A8E /* TemporaryTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */; }; DDDB86F12DF7223C00AADDAC /* DeleteAlarmSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDB86F02DF7223C00AADDAC /* DeleteAlarmSection.swift */; }; DDDC01DD2E244B3100D9975C /* JWTManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC01DC2E244B3100D9975C /* JWTManager.swift */; }; - A1A1A10002000000A0CFEED1 /* LogRedactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10002000000A0CFEED2 /* LogRedactor.swift */; }; DDDC31CC2E13A7DF009EA0F3 /* AddAlarmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */; }; DDDC31CE2E13A811009EA0F3 /* AlarmTile.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */; }; DDDF6F492D479AF000884336 /* NoRemoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDF6F482D479AEF00884336 /* NoRemoteView.swift */; }; @@ -292,7 +294,6 @@ FC1BDD3224A2585C001B652C /* DataStructs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1BDD2E24A232A3001B652C /* DataStructs.swift */; }; FC3AE7B5249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */; }; FC3CAB022493B6220068A152 /* BackgroundTaskAudio.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC688592489554800A0279D /* BackgroundTaskAudio.swift */; }; - A1A1A10001000000A0CFEED1 /* APNsCredentialValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */; }; FC5A5C3D2497B229009C550E /* Config.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = FC5A5C3C2497B229009C550E /* Config.xcconfig */; }; FC7CE518248ABE37001F83B8 /* Siri_Alert_Calibration_Needed.caf in Resources */ = {isa = PBXBuildFile; fileRef = FC7CE4A9248ABE2B001F83B8 /* Siri_Alert_Calibration_Needed.caf */; }; FC7CE519248ABE37001F83B8 /* Rise_And_Shine.caf in Resources */ = {isa = PBXBuildFile; fileRef = FC7CE4AA248ABE2B001F83B8 /* Rise_And_Shine.caf */; }; @@ -425,7 +426,6 @@ FCC6886B24898FD800A0279D /* ObservationToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC6886A24898FD800A0279D /* ObservationToken.swift */; }; FCC6886D2489909D00A0279D /* AnyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC6886C2489909D00A0279D /* AnyConvertible.swift */; }; FCC6886F2489A53800A0279D /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC6886E2489A53800A0279D /* AppConstants.swift */; }; - 9C7FB98C98BE4FF98F4815EE /* Telemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFBE69CEF18416D84959974 /* Telemetry.swift */; }; FCD2A27D24C9D044009F7B7B /* Globals.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCD2A27C24C9D044009F7B7B /* Globals.swift */; }; FCE537BC249A4D7D00F80BF8 /* carbBolusArrays.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCE537BB249A4D7D00F80BF8 /* carbBolusArrays.swift */; }; FCEF87AC24A141A700AE6FA0 /* Localizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCEF87AA24A1417900AE6FA0 /* Localizer.swift */; }; @@ -518,9 +518,13 @@ 65A100022F5AA00000AA1002 /* UnitsConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitsConfigurationView.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 = ""; }; + A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNsCredentialValidator.swift; sourceTree = ""; }; + A1A1A10002000000A0CFEED2 /* LogRedactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRedactor.swift; sourceTree = ""; }; A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshManager.swift; sourceTree = ""; }; + AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDiagnostics.swift; sourceTree = ""; }; B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsAlarmEditor.swift; sourceTree = ""; }; + BDFBE69CEF18416D84959974 /* Telemetry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Telemetry.swift; sourceTree = ""; }; DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = ""; }; DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = ""; }; DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinPrecisionManager.swift; sourceTree = ""; }; @@ -565,7 +569,6 @@ DD4878042C7B2C970048F05C /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsView.swift; sourceTree = ""; }; DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsViewModel.swift; sourceTree = ""; }; - AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDiagnostics.swift; sourceTree = ""; }; DD48780D2C7B74A40048F05C /* TrioRemoteControlViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioRemoteControlViewModel.swift; sourceTree = ""; }; DD48780F2C7B74BF0048F05C /* TrioRemoteControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioRemoteControlView.swift; sourceTree = ""; }; DD4878122C7B750D0048F05C /* TempTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetView.swift; sourceTree = ""; }; @@ -709,7 +712,6 @@ DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryTarget.swift; sourceTree = ""; }; DDDB86F02DF7223C00AADDAC /* DeleteAlarmSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAlarmSection.swift; sourceTree = ""; }; DDDC01DC2E244B3100D9975C /* JWTManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JWTManager.swift; sourceTree = ""; }; - A1A1A10002000000A0CFEED2 /* LogRedactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRedactor.swift; sourceTree = ""; }; DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAlarmSheet.swift; sourceTree = ""; }; DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmTile.swift; sourceTree = ""; }; DDDF6F482D479AEF00884336 /* NoRemoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoRemoteView.swift; sourceTree = ""; }; @@ -878,7 +880,6 @@ FCA2DDE52501095000254A8C /* Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timers.swift; sourceTree = ""; }; FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryKeyPath.swift; sourceTree = ""; }; FCC688592489554800A0279D /* BackgroundTaskAudio.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundTaskAudio.swift; sourceTree = ""; }; - A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNsCredentialValidator.swift; sourceTree = ""; }; FCC6885B2489559400A0279D /* blank.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = blank.wav; sourceTree = ""; }; FCC6885D24896A6C00A0279D /* silence.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = silence.mp3; sourceTree = ""; }; FCC6886624898F8000A0279D /* UserDefaultsValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsValue.swift; sourceTree = ""; }; @@ -886,7 +887,6 @@ FCC6886A24898FD800A0279D /* ObservationToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationToken.swift; sourceTree = ""; }; FCC6886C2489909D00A0279D /* AnyConvertible.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnyConvertible.swift; sourceTree = ""; }; FCC6886E2489A53800A0279D /* AppConstants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = ""; }; - BDFBE69CEF18416D84959974 /* Telemetry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Telemetry.swift; sourceTree = ""; }; FCC688702489A57C00A0279D /* Loop Follow.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Loop Follow.entitlements"; sourceTree = ""; }; FCD2A27C24C9D044009F7B7B /* Globals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Globals.swift; sourceTree = ""; }; FCE537BB249A4D7D00F80BF8 /* carbBolusArrays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = carbBolusArrays.swift; sourceTree = ""; }; diff --git a/LoopFollow/Stats/AggregatedStatsView.swift b/LoopFollow/Stats/AggregatedStatsView.swift index 343831003..6f313826a 100644 --- a/LoopFollow/Stats/AggregatedStatsView.swift +++ b/LoopFollow/Stats/AggregatedStatsView.swift @@ -17,6 +17,7 @@ struct AggregatedStatsView: View { @State private var loadingError = false @State private var loadingTimer: Timer? @State private var timeoutTimer: Timer? + @State private var showEndoReport = false init(viewModel: AggregatedStatsViewModel, onDismiss: (() -> Void)? = nil) { self.viewModel = viewModel @@ -108,15 +109,25 @@ struct AggregatedStatsView: View { } } ToolbarItem(placement: .navigationBarTrailing) { - Button("Refresh") { - loadingError = false - isLoadingData = true - viewModel.updateDateRange(start: startDate, end: endDate) { - isLoadingData = false + HStack { + Button { + showEndoReport = true + } label: { + Label("Endo Report", systemImage: "doc.richtext") + } + Button("Refresh") { + loadingError = false + isLoadingData = true + viewModel.updateDateRange(start: startDate, end: endDate) { + isLoadingData = false + } } } } } + .sheet(isPresented: $showEndoReport) { + EndoReportView(dataService: viewModel.dataService) + } .onAppear { loadingError = false isLoadingData = true diff --git a/LoopFollow/Stats/EndoReportGenerator.swift b/LoopFollow/Stats/EndoReportGenerator.swift new file mode 100644 index 000000000..313364df2 --- /dev/null +++ b/LoopFollow/Stats/EndoReportGenerator.swift @@ -0,0 +1,1037 @@ +// LoopFollow +// EndoReportGenerator.swift + +import PDFKit +import UIKit + +// MARK: - Config + +struct EndoReportConfig { + let patientName: String + let dateOfBirth: String + let diagnosisDate: String + let providerName: String + let insulinType: String + let aidSystem: String + let pumpDevice: String + let cgmDevice: String + let carbRatio: String + let isf: String + let basalRate: String + let targetGlucose: String + let units: String // "mg/dL" or "mmol/L" + let accentColorHex: String + let notes: String + + // Toggles + let includeGlucoseSummary: Bool + let includeInsulin: Bool + let includeNutrition: Bool + let includeTherapySettings: Bool + let includeDevices: Bool + let includeAGP: Bool + let includeDailyBreakdown: Bool + let includeFatProtein: Bool + + let startDate: Date + let endDate: Date + + var accentColor: UIColor { + UIColor(hex: accentColorHex) ?? UIColor(red: 0.137, green: 0.624, blue: 0.675, alpha: 1) + } + + var isMMOL: Bool { units == "mmol/L" } + func convert(_ mgdl: Double) -> Double { isMMOL ? mgdl * 0.0555 : mgdl } + func fmtBG(_ mgdl: Double) -> String { + isMMOL ? String(format: "%.1f", mgdl * 0.0555) : String(format: "%.0f", mgdl) + } +} + +// MARK: - Generator + +enum EndoReportGenerator { + enum ReportError: LocalizedError { + case noData + var errorDescription: String? { "No CGM data available for the selected date range." } + } + + static func generate(config: EndoReportConfig, dataService: StatsDataService) throws -> URL { + let bgData = dataService.getBGData() + guard !bgData.isEmpty else { throw ReportError.noData } + + // Use the existing ViewModels for calculations + let agpVM = AGPViewModel(dataService: dataService) + agpVM.calculateAGP() + + let stats = ReportStats(bgData: bgData, dataService: dataService) + let patterns = TimePatterns(bgData: bgData) + let boluses = dataService.getBolusData() + let smbs = dataService.getSMBData() + let carbs = dataService.getCarbData() + let basals = dataService.getBasalData() + let basalProfile = dataService.getBasalProfile() // Get basal profile here + let simpleVM = SimpleStatsViewModel(dataService: dataService) + simpleVM.calculateStats() + + let pageRect = CGRect(origin: .zero, size: CGSize(width: 612, height: 792)) + let renderer = UIGraphicsPDFRenderer(bounds: pageRect) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("EndoReport_\(Int(Date().timeIntervalSince1970)).pdf") + + let dailyData = groupByDay(bgData: bgData, boluses: boluses, smbs: smbs, basals: basals, carbs: carbs) + .sorted { $0.key > $1.key } + + let data = renderer.pdfData { ctx in + // Page 1 — Summary + ctx.beginPage() + drawSummaryPage(ctx: ctx.cgContext, r: pageRect, cfg: config, + bgData: bgData, agpData: agpVM.agpData, + stats: stats, patterns: patterns, + boluses: boluses, smbs: smbs, carbs: carbs, + simpleVM: simpleVM) + + // Pages 2+ — Daily breakdowns + if config.includeDailyBreakdown && !dailyData.isEmpty { + let rowH: CGFloat = 88 + let rowGap: CGFloat = 6 + let topY: CGFloat = 52 + let botY: CGFloat = 762 + let usable = botY - topY + let perPage = Int((usable + rowGap) / (rowH + rowGap)) + let pages = Int(ceil(Double(dailyData.count) / Double(perPage))) + + for p in 0 ..< pages { + ctx.beginPage() + let pageNum = p + 2 + let headerY = drawDailyPageHeader(ctx: ctx.cgContext, r: pageRect, + cfg: config, page: pageNum, + totalPages: pages + 1) + let slice = Array(dailyData[p * perPage ..< min((p + 1) * perPage, dailyData.count)]) + var y = headerY + 8 + for (day, dayData) in slice { + drawDayRow(ctx: ctx.cgContext, x: 28, y: y, + w: pageRect.width - 56, h: rowH, + day: day, dayData: dayData, cfg: config, basalProfile: basalProfile) + y += rowH + rowGap + } + drawFooter(ctx: ctx.cgContext, r: pageRect, cfg: config, + stats: stats, page: pageNum) + } + } + } + try data.write(to: url) + return url + } + + // MARK: - Data models + + struct ReportStats { + let avg, stdDev, cv, eA1C, minBG, maxBG, sensorPct, tir, tightTIR, days: Double + let veryLow, low, inRange, high, veryHigh: Double + let readingCount: Int + + init(bgData: [ShareGlucoseData], dataService: StatsDataService) { + let v = bgData.map { Double($0.sgv) }; let n = Double(v.count) + let m = v.reduce(0,+) / n + let variance = v.map { ($0 - m) * ($0 - m) }.reduce(0,+) / n + + avg = m; stdDev = sqrt(variance); cv = stdDev / m * 100; eA1C = (m + 46.7) / 28.7 + minBG = v.min() ?? 0; maxBG = v.max() ?? 0; readingCount = v.count + days = Swift.max(dataService.endDate.timeIntervalSince1970 - dataService.startDate.timeIntervalSince1970, 86400) / 86400 + sensorPct = Swift.min(Double(v.count) / (days * 288) * 100, 100) + + // Calculate TIR Buckets + let vLowCount = Double(v.filter { $0 < 54 }.count) + let lowCount = Double(v.filter { $0 >= 54 && $0 < 70 }.count) + let inRangeCount = Double(v.filter { $0 >= 70 && $0 <= 180 }.count) + let highCount = Double(v.filter { $0 > 180 && $0 <= 250 }.count) + let vHighCount = Double(v.filter { $0 > 250 }.count) + + veryLow = (vLowCount / n) * 100 + low = (lowCount / n) * 100 + inRange = (inRangeCount / n) * 100 + high = (highCount / n) * 100 + veryHigh = (vHighCount / n) * 100 + tir = inRange + tightTIR = Double(v.filter { $0 >= 70 && $0 <= 140 }.count) / n * 100 + } + } + + struct TimePatterns { + struct Period { let label: String; let avg: Double; let count: Int } + let night, earlyAM, morning, afternoon, evening, late: Period + init(bgData: [ShareGlucoseData]) { + func p(_ l: String, _ s: Int, _ e: Int) -> Period { + let cal = dateTimeUtils.displayCalendar() + let r = bgData.filter { let h = cal.component(.hour, from: Date(timeIntervalSince1970: $0.date)); return h >= s && h < e } + return Period(label: l, avg: r.isEmpty ? 0 : r.map { Double($0.sgv) }.reduce(0,+) / Double(r.count), count: r.count) + } + night = p("Night", 0, 3); earlyAM = p("Early AM", 3, 6); morning = p("Morning", 6, 12) + afternoon = p("Afternoon", 12, 17); evening = p("Evening", 17, 21); late = p("Late", 21, 24) + } + } + + struct DayData { + let bg: [ShareGlucoseData] + let bolus: [MainViewController.bolusGraphStruct] + let smb: [MainViewController.bolusGraphStruct] + let basal: [MainViewController.basalGraphStruct] + let carbs: [MainViewController.carbGraphStruct] + } + + private static func groupByDay( + bgData: [ShareGlucoseData], + boluses: [MainViewController.bolusGraphStruct], + smbs: [MainViewController.bolusGraphStruct], + basals: [MainViewController.basalGraphStruct], + carbs: [MainViewController.carbGraphStruct] + ) -> [String: DayData] { + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + var bg: [String: [ShareGlucoseData]] = [:] + var bo: [String: [MainViewController.bolusGraphStruct]] = [:] + var sm: [String: [MainViewController.bolusGraphStruct]] = [:] + var ba: [String: [MainViewController.basalGraphStruct]] = [:] + var ca: [String: [MainViewController.carbGraphStruct]] = [:] + for r in bgData { + let k = df.string(from: Date(timeIntervalSince1970: r.date)); bg[k, default: []].append(r) + } + for r in boluses { + let k = df.string(from: Date(timeIntervalSince1970: r.date)); bo[k, default: []].append(r) + } + for r in smbs { + let k = df.string(from: Date(timeIntervalSince1970: r.date)); sm[k, default: []].append(r) + } + for r in basals { + let k = df.string(from: Date(timeIntervalSince1970: r.date)); ba[k, default: []].append(r) + } + for r in carbs { + let k = df.string(from: Date(timeIntervalSince1970: r.date)); ca[k, default: []].append(r) + } + var result: [String: DayData] = [:] + for k in bg.keys { + result[k] = DayData(bg: bg[k]!, bolus: bo[k] ?? [], smb: sm[k] ?? [], basal: ba[k] ?? [], carbs: ca[k] ?? []) + } + return result + } + + // MARK: - Colors / fonts + + private static func accent(_ cfg: EndoReportConfig) -> UIColor { cfg.accentColor } + private static func accentDark(_ cfg: EndoReportConfig) -> UIColor { + var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + cfg.accentColor.getHue(&h, saturation: &s, brightness: &b, alpha: &a) + return UIColor(hue: h, saturation: s, brightness: b * 0.72, alpha: a) + } + + private static let C_INK = UIColor(red: 0.133, green: 0.157, blue: 0.192, alpha: 1) + private static let C_SLATE = UIColor(red: 0.400, green: 0.440, blue: 0.490, alpha: 1) + private static let C_CLOUD = UIColor(red: 0.960, green: 0.963, blue: 0.970, alpha: 1) + private static let C_BORDER = UIColor(red: 0.870, green: 0.885, blue: 0.905, alpha: 1) + private static let C_WHITE = UIColor.white + private static let C_VLOW = UIColor(red: 0.820, green: 0.180, blue: 0.180, alpha: 1) + private static let C_LOW = UIColor(red: 0.929, green: 0.490, blue: 0.188, alpha: 1) + private static let C_IN = UIColor(red: 0.200, green: 0.670, blue: 0.470, alpha: 1) + private static let C_HIGH = UIColor(red: 0.910, green: 0.740, blue: 0.220, alpha: 1) + private static let C_VHIGH = UIColor(red: 0.800, green: 0.340, blue: 0.340, alpha: 1) + private static let C_BOLUS = UIColor(red: 0.380, green: 0.220, blue: 0.780, alpha: 0.85) + private static let C_SMB = UIColor(red: 0.800, green: 0.200, blue: 0.600, alpha: 0.75) + private static let C_CARB = UIColor(red: 0.150, green: 0.600, blue: 0.150, alpha: 1.0) + private static let C_BASAL = UIColor(red: 0.102, green: 0.451, blue: 0.933, alpha: 0.65) + + private static func bgColor(_ bg: Double) -> UIColor { + switch bg { case ..<54: return C_VLOW; case ..<70: return C_LOW; case ...180: return C_IN; case ...250: return C_HIGH; default: return C_VHIGH } + } + + // MARK: - Page 1: Summary + + private static func drawSummaryPage( + ctx: CGContext, r: CGRect, cfg: EndoReportConfig, + bgData _: [ShareGlucoseData], agpData: [AGPDataPoint], + stats: ReportStats, patterns: TimePatterns, + boluses: [MainViewController.bolusGraphStruct], + smbs: [MainViewController.bolusGraphStruct], + carbs: [MainViewController.carbGraphStruct], + simpleVM: SimpleStatsViewModel + ) { + let m: CGFloat = 24 + var y = drawHero(ctx: ctx, r: r, cfg: cfg, stats: stats) + + if cfg.includeGlucoseSummary { + y = sectionHdr("GLUCOSE SUMMARY", y: y + 2, m: m, w: r.width, cfg: cfg, ctx: ctx) + + let gridW: CGFloat = r.width - m * 2 - 158 + let cw = gridW / 2 - 3; let ch: CGFloat = 36 + let cards: [(String, String, Bool)] = [ + ("TIME IN RANGE (>70%)", String(format: "%.0f%%", stats.tir), true), + ("GMI (TARGET <7%)", String(format: "%.1f%%", stats.eA1C), false), + ("AVERAGE", cfg.fmtBG(stats.avg) + " \(cfg.units)", false), + ("STD DEVIATION", cfg.fmtBG(stats.stdDev), false), + ("CV (TARGET <36%)", String(format: "%.0f%%", stats.cv), false), + ("READINGS", "\(stats.readingCount)", false), + ] + var gy = y + 1 + for (i, c) in cards.enumerated() { + statCard(c.0, val: c.1, x: m + CGFloat(i % 2) * (cw + 6), y: gy + CGFloat(i / 2) * (ch + 4), + w: cw, h: ch, accent: c.2, cfg: cfg, ctx: ctx) + } + drawTIRBar(stats: stats, x: m + gridW + 10, y: y + 1, + w: 148, h: ch * 3 + 7, cfg: cfg, ctx: ctx) + y = gy + CGFloat(3) * (ch + 4) + 1 + + y = timeStrip(patterns: patterns, cfg: cfg, y: y + 1, m: m, w: r.width, ctx: ctx) + } + + if cfg.includeInsulin && (!boluses.isEmpty || !smbs.isEmpty) { + y = sectionHdr("INSULIN DELIVERY", y: y + 2, m: m, w: r.width, cfg: cfg, ctx: ctx) + y = insulinSection(boluses: boluses, smbs: smbs, simpleVM: simpleVM, + stats: stats, cfg: cfg, y: y + 1, m: m, w: r.width, ctx: ctx) + } + + if cfg.includeNutrition && !carbs.isEmpty { + y = sectionHdr("NUTRITION & MEALS", y: y + 2, m: m, w: r.width, cfg: cfg, ctx: ctx) + y = nutritionSection(carbs: carbs, stats: stats, cfg: cfg, y: y + 1, m: m, w: r.width, ctx: ctx) + } + + let hasDevice = cfg.includeDevices && (!cfg.pumpDevice.isEmpty || !cfg.cgmDevice.isEmpty || !cfg.insulinType.isEmpty) + let hasSettings = cfg.includeTherapySettings && (!cfg.carbRatio.isEmpty || !cfg.isf.isEmpty || !cfg.basalRate.isEmpty || !cfg.targetGlucose.isEmpty) + + if hasDevice || hasSettings { + y = sectionHdr("SYSTEM & THERAPY SETTINGS", y: y + 2, m: m, w: r.width, cfg: cfg, ctx: ctx) + var gridItems: [(String, String)] = [] + if hasDevice { + if !cfg.pumpDevice.isEmpty { gridItems.append(("Pump", cfg.pumpDevice)) } + if !cfg.cgmDevice.isEmpty { gridItems.append(("CGM", cfg.cgmDevice)) } + if !cfg.insulinType.isEmpty { gridItems.append(("Insulin", cfg.insulinType)) } + } + if hasSettings { + if !cfg.carbRatio.isEmpty { gridItems.append(("CR", cfg.carbRatio)) } + if !cfg.isf.isEmpty { gridItems.append(("ISF", cfg.isf)) } + if !cfg.basalRate.isEmpty { gridItems.append(("Basal", formatBasalRateForDisplay(cfg.basalRate))) } + if !cfg.targetGlucose.isEmpty { gridItems.append(("Target", cfg.targetGlucose)) } + } + y = drawSettingsGrid(gridItems, x: m, y: y + 1, width: r.width - m * 2, cfg: cfg, ctx: ctx) + } + + if !cfg.notes.isEmpty { + y = drawNotesSection(cfg.notes, x: m, y: y + 2, width: r.width - m * 2, cfg: cfg, ctx: ctx) + } + + if cfg.includeAGP, !agpData.isEmpty { + let agpAvail = r.height - y - 40 + if agpAvail >= 80 { + y = sectionHdr("AMBULATORY GLUCOSE PROFILE", y: y + 6, m: m, w: r.width, cfg: cfg, ctx: ctx) + let agpH = Swift.min(agpAvail - 20, 130) + drawAGP(agpData: agpData, x: m, y: y + 4, w: r.width - m * 2, h: agpH, cfg: cfg, ctx: ctx) + } + } + + drawFooter(ctx: ctx, r: r, cfg: cfg, stats: stats, page: 1) + } + + // MARK: - Hero header + + @discardableResult + private static func drawHero(ctx: CGContext, r: CGRect, cfg: EndoReportConfig, stats: ReportStats) -> CGFloat { + let h: CGFloat = 90; let ac = accent(cfg); let ad = accentDark(cfg) + ctx.setFillColor(ac.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: h)) + ctx.setFillColor(ad.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: 21)) + + let a1: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8.5), .foregroundColor: C_WHITE.withAlphaComponent(0.8), .kern: 3.0] + "LOOP FOLLOW".draw(at: CGPoint(x: 26, y: 5), withAttributes: a1) + + let a2: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 21), .foregroundColor: C_WHITE] + "Endocrinologist Visit Report".draw(at: CGPoint(x: 26, y: 26), withAttributes: a2) + + let a3: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 9.5), .foregroundColor: C_WHITE.withAlphaComponent(0.82)] + "Automated Insulin Delivery Performance Summary".draw(at: CGPoint(x: 26, y: 52), withAttributes: a3) + + let df = DateFormatter(); df.dateFormat = "MMMM d, yyyy" + let ds = "\(df.string(from: cfg.startDate)) — \(df.string(from: cfg.endDate)) (\(Int(stats.days.rounded())) Days)" + let a4: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 9), .foregroundColor: C_WHITE.withAlphaComponent(0.68)] + ds.draw(at: CGPoint(x: 26, y: 68), withAttributes: a4) + + let a5: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 8.5), .foregroundColor: C_WHITE.withAlphaComponent(0.95)] + var lines: [String] = [] + if !cfg.patientName.isEmpty { lines.append("Patient: \(cfg.patientName)") } + if !cfg.providerName.isEmpty { lines.append("Provider: \(cfg.providerName)") } + if !cfg.dateOfBirth.isEmpty { lines.append("DOB: \(cfg.dateOfBirth)") } + if !cfg.aidSystem.isEmpty { lines.append("AID: \(cfg.aidSystem)") } + if !cfg.diagnosisDate.isEmpty { lines.append("Dx: \(cfg.diagnosisDate)") } + + for (i, l) in lines.enumerated() { + let sz = (l as NSString).size(withAttributes: a5) + (l as NSString).draw(at: CGPoint(x: r.width - 26 - sz.width, y: 24 + CGFloat(i) * 11.5), withAttributes: a5) + } + return h + } + + // MARK: - Daily page header + + @discardableResult + private static func drawDailyPageHeader(ctx: CGContext, r: CGRect, cfg: EndoReportConfig, + page: Int, totalPages: Int) -> CGFloat + { + let h: CGFloat = 40; let ac = accent(cfg) + ctx.setFillColor(ac.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: h)) + let a1: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 12), .foregroundColor: C_WHITE] + "Daily Glucose Breakdown".draw(at: CGPoint(x: 28, y: 11), withAttributes: a1) + let sub = "Newest to Oldest • Page \(page) of \(totalPages)" + let a2: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 8), .foregroundColor: C_WHITE.withAlphaComponent(0.75)] + let sz = (sub as NSString).size(withAttributes: a2) + (sub as NSString).draw(at: CGPoint(x: r.width - 28 - sz.width, y: 14), withAttributes: a2) + return h + } + + // MARK: - Section header + + @discardableResult + private static func sectionHdr(_ title: String, y: CGFloat, m: CGFloat, w: CGFloat, + cfg: EndoReportConfig, ctx: CGContext) -> CGFloat + { + ctx.setFillColor(accent(cfg).cgColor) + ctx.fill(CGRect(x: m, y: y, width: 3, height: 14)) + let a: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 9), .foregroundColor: accent(cfg), .kern: 0.6] + (title as NSString).draw(at: CGPoint(x: m + 8, y: y), withAttributes: a) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) + ctx.move(to: CGPoint(x: m, y: y + 15)); ctx.addLine(to: CGPoint(x: w - m, y: y + 15)); ctx.strokePath() + return y + 16 + } + + // MARK: - Stat card + + private static func statCard(_ label: String, val: String, x: CGFloat, y: CGFloat, + w: CGFloat, h: CGFloat, accent ac: Bool, + cfg: EndoReportConfig, ctx: CGContext) + { + let r = CGRect(x: x, y: y, width: w, height: h) + ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r) + if ac { + ctx.setFillColor(accent(cfg).withAlphaComponent(0.07).cgColor); ctx.fill(r) + ctx.setFillColor(accent(cfg).cgColor); ctx.fill(CGRect(x: x, y: y, width: 3, height: h)) + } + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r) + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.5] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 16), .foregroundColor: ac ? accent(cfg) : C_INK] + (label as NSString).draw(at: CGPoint(x: x + 8, y: y + 4), withAttributes: la) + (val as NSString).draw(at: CGPoint(x: x + 8, y: y + 14), withAttributes: va) + } + + // MARK: - TIR vertical bar + + private static func drawTIRBar(stats: ReportStats, + x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat, + cfg: EndoReportConfig, ctx: CGContext) + { + let r = CGRect(x: x, y: y, width: w, height: h) + ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r) + + let ta: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: C_SLATE] + "Time in Range".draw(at: CGPoint(x: x + 8, y: y + 6), withAttributes: ta) + + // Shorten bar height to allow text room + let bx = x + 10; let bw: CGFloat = 16; let by = y + 22; let bh = h - 50 + let segs: [(Double, UIColor, String)] = [ + (stats.veryHigh, C_VHIGH, "Very High"), (stats.high, C_HIGH, "High"), + (stats.inRange, C_IN, "In Range"), (stats.low, C_LOW, "Low"), + (stats.veryLow, C_VLOW, "Very Low"), + ] + var sy = by + + for (pct, clr, _) in segs { + let sh = CGFloat(pct / 100) * bh + if sh > 0 { ctx.setFillColor(clr.cgColor); ctx.fill(CGRect(x: bx, y: sy, width: bw, height: sh)) } + sy += sh + } + + // Draw a stable legend to avoid overlapping text inside a constrained vertical bar. + let legendX = bx + bw + 8 + let legendY = by + let legendSpacing: CGFloat = 12 + for (index, (pct, _, label)) in segs.filter({ $0.0 > 0.0 }).enumerated() { + let ps = String(format: "%.0f%%", pct) + let isTarget = (label == "In Range") + let pa: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: isTarget ? accent(cfg) : C_SLATE] + let textStr = "\(label) \(ps)" + let textY = legendY + CGFloat(index) * legendSpacing + (textStr as NSString).draw(at: CGPoint(x: legendX, y: textY), withAttributes: pa) + } + + let na: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + "Target: 70-180".draw(at: CGPoint(x: x + 5, y: y + h - 24), withAttributes: na) + "Time in Tight Range: 70-140".draw(at: CGPoint(x: x + 5, y: y + h - 12), withAttributes: na) + } + + // MARK: - Time-of-day strip + + @discardableResult + private static func timeStrip(patterns: TimePatterns, cfg: EndoReportConfig, + y: CGFloat, m: CGFloat, w: CGFloat, ctx: CGContext) -> CGFloat + { + let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: C_INK] + "Glucose by Time of Day (\(cfg.units))".draw(at: CGPoint(x: m, y: y), withAttributes: ha) + let periods = [patterns.night, patterns.earlyAM, patterns.morning, + patterns.afternoon, patterns.evening, patterns.late] + let cw = (w - m * 2) / CGFloat(periods.count); let ch: CGFloat = 38; let cy = y + 11 + for (i, p) in periods.enumerated() { + let cx = m + CGFloat(i) * cw + let rr = CGRect(x: cx, y: cy, width: cw - 2, height: ch) + ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(rr) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(rr) + guard p.count > 0 else { continue } + let disp = cfg.fmtBG(p.avg) + let vc: UIColor = p.avg < 70 ? C_LOW : p.avg < 140 ? accent(cfg) : p.avg < 180 ? C_INK : C_HIGH + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 13), .foregroundColor: vc] + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + let vsz = (disp as NSString).size(withAttributes: va) + let lsz = (p.label as NSString).size(withAttributes: la) + (disp as NSString).draw(at: CGPoint(x: cx + (cw - 2 - vsz.width) / 2, y: cy + 5), withAttributes: va) + (p.label as NSString).draw(at: CGPoint(x: cx + (cw - 2 - lsz.width) / 2, y: cy + 25), withAttributes: la) + } + return cy + ch + 2 + } + + // MARK: - Insulin section + + @discardableResult + private static func insulinSection(boluses: [MainViewController.bolusGraphStruct], + smbs: [MainViewController.bolusGraphStruct], + simpleVM: SimpleStatsViewModel, stats _: ReportStats, + cfg: EndoReportConfig, y: CGFloat, m: CGFloat, w: CGFloat, + ctx: CGContext) -> CGFloat + { + let tdd = simpleVM.totalDailyDose ?? 0 + let basalPct = tdd > 0 ? (simpleVM.actualBasal ?? 0) / tdd * 100 : 0 + let bolusPct = tdd > 0 ? (simpleVM.avgBolus ?? 0) / tdd * 100 : 0 + let cards: [(String, String)] = [("AVG TDD", tdd > 0 ? String(format: "%.1fU", tdd) : "—"), + ("BASAL", basalPct > 0 ? String(format: "%.0f%%", basalPct) : "—"), + ("BOLUS", bolusPct > 0 ? String(format: "%.0f%%", bolusPct) : "—")] + let cw = (w - m * 2) / 3 - 4; let ch: CGFloat = 36 + for (i, c) in cards.enumerated() { + let cx = m + CGFloat(i) * (cw + 4) + let r2 = CGRect(x: cx, y: y, width: cw, height: ch) + ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r2) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r2) + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.4] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 16), .foregroundColor: C_INK] + (c.0 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 4), withAttributes: la) + (c.1 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 14), withAttributes: va) + } + let ty = y + ch + 2 + let total = (boluses + smbs).map { $0.value }.reduce(0,+) + let rows: [(String, String)] = [ + ("Correction Boluses", "\(boluses.count)"), + ("SMB / Auto-Corrections", "\(smbs.count)"), + ("Total Bolus Insulin", String(format: "%.1f U", total)), + ("Programmed Basal", simpleVM.programmedBasal != nil ? String(format: "%.2f U/day", simpleVM.programmedBasal!) : "—"), + ("Actual Basal", simpleVM.actualBasal != nil ? String(format: "%.2f U/day", simpleVM.actualBasal!) : "—"), + ] + return metricTable(rows, x: m, y: ty, width: w - m * 2, cfg: cfg, ctx: ctx) + } + + // MARK: - Nutrition section + + @discardableResult + private static func nutritionSection(carbs: [MainViewController.carbGraphStruct], + stats: ReportStats, cfg _: EndoReportConfig, + y: CGFloat, m: CGFloat, w: CGFloat, ctx: CGContext) -> CGFloat + { + let total = carbs.map { $0.value }.reduce(0,+) + let cards: [(String, String)] = [ + ("DAILY CARBS", String(format: "%.0fg", total / stats.days)), + ("MEALS LOGGED", "\(carbs.count)"), + ("PER MEAL AVG", String(format: "%.0fg", carbs.isEmpty ? 0 : total / Double(carbs.count))), + ] + let cw = (w - m * 2) / 3 - 4; let ch: CGFloat = 36 + for (i, c) in cards.enumerated() { + let cx = m + CGFloat(i) * (cw + 4) + let r2 = CGRect(x: cx, y: y, width: cw, height: ch) + ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r2) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r2) + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.4] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 16), .foregroundColor: C_INK] + (c.0 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 4), withAttributes: la) + (c.1 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 14), withAttributes: va) + } + return y + ch + 2 + } + + // MARK: - Tables + + @discardableResult + private static func metricTable(_ rows: [(String, String)], x: CGFloat, y: CGFloat, + width: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat + { + let tw = width; let hh: CGFloat = 12; let rh: CGFloat = 11; var cy = y + ctx.setFillColor(accent(cfg).withAlphaComponent(0.10).cgColor) + ctx.fill(CGRect(x: x, y: cy, width: tw, height: hh)) + let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg), .kern: 0.4] + "METRIC".draw(at: CGPoint(x: x + 6, y: cy + 1), withAttributes: ha) + "VALUE".draw(at: CGPoint(x: x + tw * 0.58 + 6, y: cy + 1), withAttributes: ha) + cy += hh + for (i, row) in rows.enumerated() { + ctx.setFillColor((i % 2 == 0 ? C_WHITE : C_CLOUD).cgColor) + ctx.fill(CGRect(x: x, y: cy, width: tw, height: rh)) + let ka: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 7), .foregroundColor: C_INK] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg)] + (row.0 as NSString).draw(at: CGPoint(x: x + 6, y: cy + 1), withAttributes: ka) + (row.1 as NSString).draw(at: CGPoint(x: x + tw * 0.58 + 6, y: cy + 1), withAttributes: va) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.3) + ctx.move(to: CGPoint(x: x, y: cy + rh)); ctx.addLine(to: CGPoint(x: x + tw, y: cy + rh)); ctx.strokePath() + cy += rh + } + return cy + 1 + } + + // Dynamic settings table to handle multi-line text input neatly + @discardableResult + private static func settingsTable(_ rows: [(String, String)], x: CGFloat, y: CGFloat, + width: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat + { + let tw = width + let headerH: CGFloat = 12 + var cy = y + + ctx.setFillColor(accent(cfg).withAlphaComponent(0.10).cgColor) + ctx.fill(CGRect(x: x, y: cy, width: tw, height: headerH)) + + let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg), .kern: 0.4] + "THERAPY SETTING & VALUES".draw(at: CGPoint(x: x + 6, y: cy + 3), withAttributes: ha) + cy += headerH + + for (i, row) in rows.enumerated() { + let lines = row.1.components(separatedBy: "\n").filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + let rh = 11.0 + CGFloat(lines.count) * 10.5 + 4.0 + + ctx.setFillColor((i % 2 == 0 ? C_WHITE : C_CLOUD).cgColor) + ctx.fill(CGRect(x: x, y: cy, width: tw, height: rh)) + + let ka: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 7.5), .foregroundColor: C_SLATE] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: accent(cfg)] + + (row.0 as NSString).draw(at: CGPoint(x: x + 6, y: cy + 3.5), withAttributes: ka) + + var ly = cy + 12.5 + for line in lines { + (line as NSString).draw(at: CGPoint(x: x + 6, y: ly), withAttributes: va) + ly += 10.5 + } + + ctx.setStrokeColor(C_BORDER.cgColor) + ctx.setLineWidth(0.3) + ctx.move(to: CGPoint(x: x, y: cy + rh)) + ctx.addLine(to: CGPoint(x: x + tw, y: cy + rh)) + ctx.strokePath() + + cy += rh + } + return cy + 2 + } + + // MARK: - AGP + + private static func drawAGP(agpData: [AGPDataPoint], x: CGFloat, y: CGFloat, + w: CGFloat, h: CGFloat, cfg: EndoReportConfig, ctx: CGContext) + { + guard !agpData.isEmpty else { return } + let lPad: CGFloat = 26; let bPad: CGFloat = 24 + let cw = w - lPad; let ch = h - bPad + let cx = x + lPad; let cy = y + + ctx.setFillColor(UIColor(white: 0.985, alpha: 1).cgColor) + ctx.fill(CGRect(x: cx, y: cy, width: cw, height: ch)) + + let bgMin: CGFloat = 40; let bgRng: CGFloat = 320 + func gy(_ g: Double) -> CGFloat { cy + ch - (CGFloat(g) - bgMin) / bgRng * ch } + func tx(_ mins: Int) -> CGFloat { cx + CGFloat(mins) / (24 * 60) * cw } + + ctx.setFillColor(C_IN.withAlphaComponent(0.07).cgColor) + ctx.fill(CGRect(x: cx, y: gy(180), width: cw, height: gy(70) - gy(180))) + + ctx.setLineDash(phase: 0, lengths: [3, 2]) + for (val, clr) in [(70.0, C_LOW), (180.0, C_HIGH)] { + ctx.setStrokeColor(clr.withAlphaComponent(0.5).cgColor); ctx.setLineWidth(0.6) + ctx.move(to: CGPoint(x: cx, y: gy(val))); ctx.addLine(to: CGPoint(x: cx + cw, y: gy(val))); ctx.strokePath() + } + ctx.setLineDash(phase: 0, lengths: []) + + var band = CGMutablePath() + for (i, pt) in agpData.enumerated() { + let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p95)); i == 0 ? band.move(to: p) : band.addLine(to: p) + } + for pt in agpData.reversed() { + band.addLine(to: CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p5))) + } + band.closeSubpath() + ctx.setFillColor(accent(cfg).withAlphaComponent(0.10).cgColor); ctx.addPath(band); ctx.fillPath() + + var iqr = CGMutablePath() + for (i, pt) in agpData.enumerated() { + let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p75)); i == 0 ? iqr.move(to: p) : iqr.addLine(to: p) + } + for pt in agpData.reversed() { + iqr.addLine(to: CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p25))) + } + iqr.closeSubpath() + ctx.setFillColor(accent(cfg).withAlphaComponent(0.25).cgColor); ctx.addPath(iqr); ctx.fillPath() + + ctx.setStrokeColor(accent(cfg).cgColor); ctx.setLineWidth(1.6) + var first = true + for pt in agpData { + let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p50)); first ? ctx.move(to: p) : ctx.addLine(to: p); first = false + } + ctx.strokePath() + + let axA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + for bg in [70, 140, 180, 250] { + let ly = gy(Double(bg)); guard ly >= cy, ly <= cy + ch else { continue } + let lbl = cfg.isMMOL ? String(format: "%.1f", Double(bg) * 0.0555) : "\(bg)" + let lsz = (lbl as NSString).size(withAttributes: axA) + (lbl as NSString).draw(at: CGPoint(x: x + lPad - lsz.width - 3, y: ly - lsz.height / 2), withAttributes: axA) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.25) + ctx.move(to: CGPoint(x: cx, y: ly)); ctx.addLine(to: CGPoint(x: cx + cw, y: ly)); ctx.strokePath() + } + + for h2 in stride(from: 0, through: 24, by: 3) { + let lx = tx(h2 * 60) + let lbl = String(format: "%02d:00", h2) + let lsz = (lbl as NSString).size(withAttributes: axA) + let dx = Swift.max(cx, Swift.min(cx + cw - lsz.width, lx - lsz.width / 2)) + (lbl as NSString).draw(at: CGPoint(x: dx, y: cy + ch + 3), withAttributes: axA) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.25) + ctx.move(to: CGPoint(x: lx, y: cy)); ctx.addLine(to: CGPoint(x: lx, y: cy + ch)); ctx.strokePath() + } + + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) + ctx.stroke(CGRect(x: cx, y: cy, width: cw, height: ch)) + + let lgA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + let lgItems: [(String, UIColor, Bool)] = [("Median", accent(cfg), false), + ("25–75th", accent(cfg).withAlphaComponent(0.4), true), + ("5–95th", accent(cfg).withAlphaComponent(0.18), true)] + var lgX = cx + cw + for item in lgItems.reversed() { + let lsz = (item.0 as NSString).size(withAttributes: lgA) + lgX -= lsz.width + (item.0 as NSString).draw(at: CGPoint(x: lgX, y: cy + ch + 11), withAttributes: lgA) + lgX -= 15 + item.2 ? { ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x: lgX, y: cy + ch + 12, width: 12, height: 8)) }() + : { ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x: lgX, y: cy + ch + 15, width: 12, height: 2)) }() + lgX -= 5 + } + } + + @discardableResult + private static func drawSettingsGrid(_ items: [(String, String)], x: CGFloat, y: CGFloat, width: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat { + let count = CGFloat(items.count) + guard count > 0 else { return y } + let spacing: CGFloat = 4 + let cw = (width - (count - 1) * spacing) / count + var maxH: CGFloat = 0 + + for (i, item) in items.enumerated() { + let cx = x + CGFloat(i) * (cw + spacing) + let lines = item.1.components(separatedBy: "\n").filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + let h = 12.0 + CGFloat(lines.count) * 9.5 + 4.0 + maxH = max(maxH, h) + + ctx.setFillColor(C_CLOUD.cgColor) + ctx.fill(CGRect(x: cx, y: y, width: cw, height: h)) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(CGRect(x: cx, y: y, width: cw, height: h)) + + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.8), .foregroundColor: C_SLATE] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg)] + + (item.0 as NSString).draw(at: CGPoint(x: cx + 4, y: y + 2.5), withAttributes: la) + var ly = y + 10.5 + for line in lines { + (line as NSString).draw(at: CGPoint(x: cx + 4, y: ly), withAttributes: va) + ly += 9.5 + } + } + return y + maxH + 4 + } + + @discardableResult + private static func drawNotesSection(_ notes: String, x: CGFloat, y: CGFloat, width: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat { + let headerY = sectionHdr("NOTES & OBSERVATIONS", y: y, m: x, w: width + x * 2, cfg: cfg, ctx: ctx) + let font = UIFont.systemFont(ofSize: 8) + let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: C_INK] + + let textRect = CGRect(x: x + 6, y: headerY + 4, width: width - 12, height: 1000) + let size = (notes as NSString).boundingRect(with: textRect.size, options: .usesLineFragmentOrigin, attributes: attributes, context: nil).size + + let drawRect = CGRect(x: x + 6, y: headerY + 4, width: width - 12, height: size.height) + (notes as NSString).draw(in: drawRect, withAttributes: attributes) + + return headerY + 4 + size.height + 4 + } + + // MARK: - Format helpers + + private static func formatBasalRateForDisplay(_ input: String) -> String { + let lines = input.components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + // Helper to extract a double from a string that might contain units or other text + func extractDouble(_ s: String) -> Double? { + let cleaned = s.replacingOccurrences(of: ",", with: ".") + .components(separatedBy: CharacterSet(charactersIn: "0123456789.").inverted) + .joined() + return Double(cleaned) + } + + if input.contains("=") || (input.contains(":") && lines.count > 1) { + var formatted: [String] = [] + for line in lines { + let sep = line.contains("=") ? "=" : ":" + let parts = line.components(separatedBy: sep).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + if parts.count >= 2, let last = parts.last, let rate = extractDouble(last) { + let timeKey = parts.dropLast().joined(separator: sep) + formatted.append("\(timeKey) = \(String(format: "%.2f", rate))") + } else { + formatted.append(line) + } + } + return formatted.isEmpty ? input : formatted.joined(separator: "\n") + } + + if let value = extractDouble(input) { + return String(format: "%.2f U/hr", value) + } + return input + } + + // MARK: - Basal Profile Helpers + + static func calculateDailyProgrammedBasal(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 = (i < sortedProfile.count - 1) ? Double(sortedProfile[i + 1].timeAsSeconds) : Double(secondsInDay) + let durationHours = (nextTime - currentTime) / 3600.0 + totalBasal += current.value * durationHours + } + + return totalBasal + } + + // MARK: - Day row + + private static func drawDayRow(ctx: CGContext, x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat, + day: String, dayData: DayData, cfg: EndoReportConfig, + basalProfile: [MainViewController.basalProfileStruct]) + { + ctx.setFillColor(C_WHITE.cgColor); ctx.fill(CGRect(x: x, y: y, width: w, height: h)) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) + ctx.stroke(CGRect(x: x, y: y, width: w, height: h)) + + ctx.setFillColor(cfg.accentColor.cgColor) + ctx.fill(CGRect(x: x, y: y, width: 3, height: h)) + + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + let df2 = DateFormatter(); df2.dateFormat = "EEEE, MMM d, yyyy" + let date = df.date(from: day) ?? Date() + let dlA: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 9), .foregroundColor: C_INK] + df2.string(from: date).draw(at: CGPoint(x: x + 10, y: y + 5), withAttributes: dlA) + + // Statistics Container on the Right + let statsW: CGFloat = 115 + let statsX = x + w - statsW + let boxRect = CGRect(x: statsX, y: y + 1, width: statsW - 1, height: h - 2) + ctx.setFillColor(C_CLOUD.cgColor) + ctx.fill(boxRect) + + ctx.setStrokeColor(C_BORDER.cgColor) + ctx.setLineWidth(0.4) + ctx.move(to: CGPoint(x: statsX, y: y + 1)) + ctx.addLine(to: CGPoint(x: statsX, y: y + h - 1)) + ctx.strokePath() + + let vals = dayData.bg.map { Double($0.sgv) } + if !vals.isEmpty { + let n = Double(vals.count) + let avg = vals.reduce(0,+) / n + let tir = Double(vals.filter { $0 >= 70 && $0 <= 180 }.count) / n * 100 + let totalInsulin = dayData.bolus.map { $0.value }.reduce(0, +) + dayData.smb.map { $0.value }.reduce(0, +) + let dailyProgrammedBasal = calculateDailyProgrammedBasal(basalProfile: basalProfile) + + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: C_SLATE] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: C_INK] + let tirC: UIColor = tir >= 70 ? C_IN : tir >= 50 ? C_HIGH : C_VLOW + let tirA: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: tirC] + + let padding: CGFloat = 8 + let col2X = statsX + statsW / 2 + + // Row 1: Avg & TIR + "Avg BG".draw(at: CGPoint(x: statsX + padding, y: y + 8), withAttributes: la) + cfg.fmtBG(avg).draw(at: CGPoint(x: statsX + padding, y: y + 15), withAttributes: va) + + "TIR".draw(at: CGPoint(x: col2X, y: y + 8), withAttributes: la) + String(format: "%.0f%%", tir).draw(at: CGPoint(x: col2X, y: y + 15), withAttributes: tirA) + + // Divider + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.3) + ctx.move(to: CGPoint(x: statsX + 5, y: y + 30)) + ctx.addLine(to: CGPoint(x: x + w - 5, y: y + 30)) + ctx.strokePath() + + // Row 2: Bolus & Basal + "Bolus Total".draw(at: CGPoint(x: statsX + padding, y: y + 36), withAttributes: la) + String(format: "%.1f U", totalInsulin).draw(at: CGPoint(x: statsX + padding, y: y + 43), withAttributes: va) + + "Basal Sched".draw(at: CGPoint(x: col2X, y: y + 36), withAttributes: la) + String(format: "%.1f U", dailyProgrammedBasal).draw(at: CGPoint(x: col2X, y: y + 43), withAttributes: va) + + // Divider + ctx.move(to: CGPoint(x: statsX + 5, y: y + 58)) + ctx.addLine(to: CGPoint(x: x + w - 5, y: y + 58)) + ctx.strokePath() + + // Row 3: Coverage + "Data Coverage".draw(at: CGPoint(x: statsX + padding, y: y + 64), withAttributes: la) + let coverage = String(format: "%.0f%%", Double(vals.count) / 2.88) + "\(vals.count) pts (\(coverage))".draw(at: CGPoint(x: statsX + padding, y: y + 71), withAttributes: va) + } + + let chartX = x + 10; let chartW = w - 140 + let chartY = y + 26; let chartH = h - 32 + + guard !dayData.bg.isEmpty else { return } + + ctx.saveGState() + ctx.clip(to: CGRect(x: chartX, y: chartY, width: chartW, height: chartH)) + + let bgMin: CGFloat = 40; let bgMax: CGFloat = 320; let bgRng = bgMax - bgMin + func gy(_ bg: Double) -> CGFloat { chartY + chartH - (CGFloat(bg) - bgMin) / bgRng * chartH } + func tx(_ ts: Double) -> CGFloat { + let cal = dateTimeUtils.displayCalendar() + let d = Date(timeIntervalSince1970: ts) + let c = cal.dateComponents([.hour, .minute], from: d) + let min = Double((c.hour ?? 0) * 60 + (c.minute ?? 0)) + return chartX + CGFloat(min / (24 * 60)) * chartW + } + + ctx.setFillColor(C_IN.withAlphaComponent(0.06).cgColor) + ctx.fill(CGRect(x: chartX, y: gy(180), width: chartW, height: gy(70) - gy(180))) + + ctx.setLineDash(phase: 0, lengths: [2, 2]); ctx.setLineWidth(0.4) + ctx.setStrokeColor(C_LOW.withAlphaComponent(0.4).cgColor) + ctx.move(to: CGPoint(x: chartX, y: gy(70))); ctx.addLine(to: CGPoint(x: chartX + chartW, y: gy(70))); ctx.strokePath() + ctx.setStrokeColor(C_HIGH.withAlphaComponent(0.4).cgColor) + ctx.move(to: CGPoint(x: chartX, y: gy(180))); ctx.addLine(to: CGPoint(x: chartX + chartW, y: gy(180))); ctx.strokePath() + ctx.setLineDash(phase: 0, lengths: []) + + ctx.setStrokeColor(C_BORDER.withAlphaComponent(0.5).cgColor); ctx.setLineWidth(0.25) + for h2 in stride(from: 3, through: 21, by: 3) { + let hx = chartX + CGFloat(h2) / 24 * chartW + ctx.move(to: CGPoint(x: hx, y: chartY)); ctx.addLine(to: CGPoint(x: hx, y: chartY + chartH)); ctx.strokePath() + } + + if !dayData.basal.isEmpty { + let bH = chartH * 0.25; let bY = chartY + chartH - bH + let sorted = dayData.basal.sorted { $0.date < $1.date } + let maxR = Swift.max(sorted.map { $0.basalRate }.max() ?? 1, 0.01) + + var path = CGMutablePath(); var first = true + for pt in sorted { + let px = tx(pt.date); let py = bY + bH - CGFloat(pt.basalRate / maxR) * bH + first ? path.move(to: CGPoint(x: px, y: py)) : path.addLine(to: CGPoint(x: px, y: py)); first = false + } + if let last = sorted.last { + path.addLine(to: CGPoint(x: tx(last.date), y: bY + bH)) + path.addLine(to: CGPoint(x: chartX, y: bY + bH)); path.closeSubpath() + ctx.setFillColor(C_BASAL.withAlphaComponent(0.15).cgColor); ctx.addPath(path); ctx.fillPath() + } + + var lp = CGMutablePath(); first = true + for (index, pt) in sorted.enumerated() { + let px = tx(pt.date); let py = bY + bH - CGFloat(pt.basalRate / maxR) * bH + first ? lp.move(to: CGPoint(x: px, y: py)) : lp.addLine(to: CGPoint(x: px, y: py)); first = false + + if pt.basalRate > 0.01 { + let nextX = index < sorted.count - 1 ? tx(sorted[index + 1].date) : (chartX + chartW) + if (nextX - px) > 14 { + let rateStr = String(format: "%.2f", pt.basalRate) + let rA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 4.2), .foregroundColor: C_BASAL] + rateStr.draw(at: CGPoint(x: px + 1, y: py - 7), withAttributes: rA) + } + } + } + ctx.setStrokeColor(C_BASAL.cgColor); ctx.setLineWidth(0.9); ctx.addPath(lp); ctx.strokePath() + } + + // Draw Carbs as small green diamonds/circles at the top of the chart + for carb in dayData.carbs { + let cx = tx(carb.date) + let cy = chartY + 4 + ctx.setFillColor(C_CARB.cgColor) + ctx.fillEllipse(in: CGRect(x: cx - 2.5, y: cy - 2.5, width: 5, height: 5)) + } + + for smb in dayData.smb { + let bx = tx(smb.date); let bh2 = max(CGFloat(Swift.min(smb.value / 15, 1)) * (chartH * 0.35), 2.5) + ctx.setFillColor(C_SMB.cgColor) + ctx.fill(CGRect(x: bx - 2, y: chartY + chartH - bh2, width: 4, height: bh2)) + } + + for bolus in dayData.bolus { + let bx = tx(bolus.date); let bh2 = max(CGFloat(Swift.min(bolus.value / 15, 1)) * (chartH * 0.4), 3.0) + ctx.setFillColor(C_BOLUS.cgColor) + ctx.fill(CGRect(x: bx - 2.5, y: chartY + chartH - bh2, width: 5, height: bh2)) + } + + let sortedBG = dayData.bg.sorted(by: { $0.date < $1.date }) + for r in sortedBG { + let rx = tx(r.date); let ry = gy(Double(r.sgv)) + ctx.setFillColor(bgColor(Double(r.sgv)).cgColor) + ctx.fillEllipse(in: CGRect(x: rx - 1.6, y: ry - 1.6, width: 3.2, height: 3.2)) + } + + ctx.restoreGState() + + let axA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: C_SLATE] + for h2 in [0, 6, 12, 18, 24] { + let hx = chartX + CGFloat(h2) / 24 * chartW + let lbl = String(format: "%02d", h2) + let sz = (lbl as NSString).size(withAttributes: axA) + (lbl as NSString).draw(at: CGPoint(x: hx - sz.width / 2, y: chartY + chartH + 2), withAttributes: axA) + } + + // Legend moved to top area next to date + let lgA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: C_SLATE] + var lgX = x + 120 + for (lbl, clr) in [("● BG", C_IN), ("● Carbs", C_CARB), ("▮ Bolus", C_BOLUS), ("▮ SMB", C_SMB), ("— Basal", C_BASAL)] { + let a: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: clr] + (lbl as NSString).draw(at: CGPoint(x: lgX, y: y + 7), withAttributes: a) + lgX += (lbl as NSString).size(withAttributes: lgA).width + 5 + if lgX > statsX - 4 { break } + } + } + + // MARK: - Footer + + private static func drawFooter(ctx: CGContext, r: CGRect, cfg _: EndoReportConfig, + stats: ReportStats, page: Int) + { + let fy = r.height - 28 + ctx.setFillColor(C_INK.cgColor); ctx.fill(CGRect(x: 0, y: fy, width: r.width, height: 28)) + let a: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_WHITE.withAlphaComponent(0.5)] + "Loop Follow — for informational purposes only. Not a substitute for professional medical advice." + .draw(at: CGPoint(x: 30, y: fy + 4), withAttributes: a) + let df = DateFormatter(); df.dateFormat = "MMM d, yyyy" + let meta = "Generated: \(df.string(from: Date())) • \(Int(stats.days.rounded())) Days • \(stats.readingCount) readings • Page \(page)" + let msz = (meta as NSString).size(withAttributes: a) + (meta as NSString).draw(at: CGPoint(x: r.width - 30 - msz.width, y: fy + 4), withAttributes: a) + } +} diff --git a/LoopFollow/Stats/EndoReportView.swift b/LoopFollow/Stats/EndoReportView.swift new file mode 100644 index 000000000..75b17d9d2 --- /dev/null +++ b/LoopFollow/Stats/EndoReportView.swift @@ -0,0 +1,912 @@ +// LoopFollow +// EndoReportView.swift + +import SwiftUI + +struct EndoReportView: View { + let dataService: StatsDataService + + @Environment(\.dismiss) private var dismiss + + // Persisted patient/clinic info + @AppStorage("endoReport.patientName") private var patientName = "" + @AppStorage("endoReport.dateOfBirth") private var dateOfBirth = "" + @AppStorage("endoReport.providerName") private var providerName = "" + @AppStorage("endoReport.insulinType") private var insulinType = "" + @AppStorage("endoReport.diagnosisDate") private var diagnosisDate = "" + @AppStorage("endoReport.aidSystem") private var aidSystem = "Loop" + @AppStorage("endoReport.pumpDevice") private var pumpDevice = "" + @AppStorage("endoReport.cgmDevice") private var cgmDevice = "" + @AppStorage("endoReport.units") private var units = "mg/dL" + @AppStorage("endoReport.accentColorHex") private var accentColorHex = "#23A0AC" + + // Optional Toggle Modules + @AppStorage("endoReport.includeGlucoseSummary") private var includeGlucoseSummary = true + @AppStorage("endoReport.includeInsulin") private var includeInsulin = true + @AppStorage("endoReport.includeNutrition") private var includeNutrition = true + @AppStorage("endoReport.includeTherapySettings") private var includeTherapySettings = true + @AppStorage("endoReport.includeDevices") private var includeDevices = true + @AppStorage("endoReport.includeAGP") private var includeAGP = true + @AppStorage("endoReport.includeDailyBreakdown") private var includeDailyBreakdown = true + @AppStorage("endoReport.includeFatProtein") private var includeFatProtein = false + + // Therapy settings (manual entry) + @AppStorage("endoReport.carbRatio") private var carbRatio = "" + @AppStorage("endoReport.isf") private var isf = "" + @AppStorage("endoReport.basalRate") private var basalRate = "" + @AppStorage("endoReport.targetGlucose") private var targetGlucose = "" + @AppStorage("endoReport.customAidSystem") private var customAidSystem = "" + @AppStorage("endoReport.notes") private var notes = "" + + // Date range + @State private var startDate: Date = StatsDateRange.lastComplete(days: 14).start + @State private var endDate: Date = StatsDateRange.lastComplete(days: 14).end + + // UI state + @StateObject private var profileFetcher = NightscoutProfileFetcher() + @State private var isGenerating = false + @State private var reportURL: URL? + @State private var errorMessage: String? + @State private var showShareSheet = false + @State private var pickedColor: Color = .init(hex: "#23A0AC") ?? .teal + @State private var fetchSuccess = false + @State private var showTherapyScheduleExamples = false + @State private var therapyMode: TherapyInputMode = .simple + + let aidOptions = ["Trio", "Loop", "iAPS", "Other"] + let unitOptions = ["mg/dL", "mmol/L"] + + var body: some View { + NavigationView { + ScrollView(showsIndicators: false) { + VStack(spacing: 12) { + sectionCard("Report Period", icon: "calendar", color: .blue) { + VStack(spacing: 16) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(presets, id: \.label) { p in + Button(action: { + startDate = p.start; endDate = p.end + }) { + Text(p.label) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(isActive(p) ? .teal : .secondary) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(isActive(p) ? Color.teal.opacity(0.16) : Color(UIColor.systemGray5)) + .cornerRadius(12) + } + } + } + .padding(.vertical, 4) + } + + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text("Start") + .font(.subheadline) + .foregroundColor(.secondary) + DatePicker("", selection: $startDate, in: ...endDate, displayedComponents: .date) + .labelsHidden() + .datePickerStyle(.compact) + .frame(maxWidth: .infinity, alignment: .leading) + } + VStack(alignment: .leading, spacing: 6) { + Text("End") + .font(.subheadline) + .foregroundColor(.secondary) + DatePicker("", selection: $endDate, in: startDate..., displayedComponents: .date) + .labelsHidden() + .datePickerStyle(.compact) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + + sectionCard("Patient Information", icon: "person.fill", color: .indigo) { + VStack(spacing: 16) { + row("Name", placeholder: "Full name", text: $patientName) + row("DOB", placeholder: "MM/DD/YYYY", text: $dateOfBirth) + row("Diagnosed", placeholder: "Year (optional)", text: $diagnosisDate) + row("Provider", placeholder: "Dr. Name", text: $providerName) + } + } + + sectionCard("Devices & System", icon: "iphone.radiowaves.left.and.right", color: .teal) { + VStack(spacing: 16) { + HStack { + Text("AID System") + .foregroundColor(.secondary) + .font(.subheadline) + Spacer() + Picker("AID System", selection: $aidSystem) { + ForEach(aidOptions, id: \.self) { Text($0) } + } + .pickerStyle(.menu) + } + + if aidSystem == "Other" { + row("Custom AID", placeholder: "Enter AID system", text: $customAidSystem) + } + + row("Pump", placeholder: "e.g. Omnipod 5", text: $pumpDevice) + row("CGM", placeholder: "e.g. Dexcom G7", text: $cgmDevice) + row("Insulin", placeholder: "e.g. Humalog", text: $insulinType) + } + } + + sectionCard("Therapy Settings", icon: "slider.horizontal.3", color: .orange) { + VStack(spacing: 16) { + Button(action: fetchFromNightscout) { + HStack { + if profileFetcher.isFetching { + ProgressView().scaleEffect(0.8) + Text("Fetching from Nightscout…") + .font(.subheadline) + } else { + Image(systemName: fetchSuccess ? "checkmark.circle.fill" : "arrow.down.circle") + .foregroundColor(fetchSuccess ? .green : .accentColor) + Text(fetchSuccess ? "Settings Fetched!" : "Auto-Fill from Nightscout") + .font(.subheadline) + } + Spacer() + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 14).fill(Color(UIColor.systemGray6))) + } + .disabled(profileFetcher.isFetching) + + if let fetchErr = profileFetcher.error { + Label(fetchErr, systemImage: "exclamationmark.triangle.fill") + .foregroundColor(.red) + .font(.caption) + } + + Picker("Input mode", selection: $therapyMode) { + ForEach(TherapyInputMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .padding(.vertical, 8) + + Text("Simple values are best for most users. Use schedule mode only if you have time-based settings.") + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + + // Legend for schedule previews + HStack(spacing: 16) { + HStack(spacing: 8) { + Circle().fill(Color.mint).frame(width: 10, height: 10) + Text("Carb Ratio").font(.caption).foregroundColor(.secondary) + } + HStack(spacing: 8) { + Circle().fill(Color.indigo).frame(width: 10, height: 10) + Text("ISF").font(.caption).foregroundColor(.secondary) + } + HStack(spacing: 8) { + Circle().fill(Color.orange).frame(width: 10, height: 10) + Text("Basal Rate").font(.caption).foregroundColor(.secondary) + } + Spacer() + } + + if therapyMode == .simple { + row("Carb Ratio", placeholder: "10", text: $carbRatio, keyboard: .decimalPad) + row("ISF", placeholder: "1.8", text: $isf, keyboard: .decimalPad) + row("Basal Rate (U/hr)", placeholder: "0.80", text: $basalRate, keyboard: .decimalPad) + row("Target BG", placeholder: "100–120", text: $targetGlucose, keyboard: .numbersAndPunctuation) + } else { + therapySettingRow( + "Carb Ratio (g/U)", icon: "leaf.fill", text: $carbRatio, + placeholder: "00:00 = 10", + help: "Schedule one entry per line.", + keyboard: .decimalPad + ) + + // Preview for carb ratio schedule + therapySchedulePreview(for: carbRatio, title: "Carb Ratio Schedule Preview", accent: .mint) + + therapySettingRow( + "ISF (per U)", icon: "drop.fill", text: $isf, + placeholder: "00:00 = 45", + help: "Schedule one entry per line.", + keyboard: .decimalPad + ) + + // Preview for ISF schedule + therapySchedulePreview(for: isf, title: "ISF Schedule Preview", accent: .indigo) + + therapySettingRow( + "Basal Rate (U/hr)", icon: "waveform.path.ecg", text: $basalRate, + placeholder: "00:00 = 0.8", + help: "Schedule one entry per line.", + keyboard: .decimalPad + ) + + // Basal preview (already present) + VStack(spacing: 8) { + therapySchedulePreview(for: basalRate, title: "Basal Rate Schedule Preview", accent: .orange) + Text("Format: HH:MM = rate (e.g., 00:00 = 0.8, 06:00 = 1.0)").font(.caption2).foregroundColor(.secondary) + } + + row("Target BG", placeholder: "100–120", text: $targetGlucose, keyboard: .numbersAndPunctuation) + + DisclosureGroup(isExpanded: $showTherapyScheduleExamples) { + VStack(alignment: .leading, spacing: 8) { + Text("Schedule examples:") + .font(.subheadline) + .fontWeight(.semibold) + Text("00:00 = 10\n06:00 = 9\n12:00 = 11\n18:00 = 10") + .font(.caption) + .foregroundColor(.secondary) + .padding(10) + .background(RoundedRectangle(cornerRadius: 12).fill(Color(UIColor.systemGray6))) + Text("Use this mode only if you need multiple time-based values.") + .font(.caption) + .foregroundColor(.secondary) + } + } label: { + Text("Show schedule entry examples") + .font(.subheadline) + } + .padding(.top, 4) + } + } + } + + sectionCard("Clinician Notes", icon: "pencil.and.outline", color: .gray) { + VStack(alignment: .leading, spacing: 8) { + TextEditor(text: $notes) + .padding(10) + .background(RoundedRectangle(cornerRadius: 14).fill(Color(UIColor.systemGray6))) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(Color.secondary.opacity(0.16), lineWidth: 1) + ) + .frame(minHeight: 100) + } + } + + sectionCard("Included Report Modules", icon: "checklist", color: .green) { + VStack(spacing: 12) { + toggleRow("Glucose Summary & TIR", isOn: $includeGlucoseSummary) + toggleRow("Insulin Delivery", isOn: $includeInsulin) + toggleRow("Nutrition & Meals", isOn: $includeNutrition) + toggleRow("Current Therapy Settings", isOn: $includeTherapySettings) + toggleRow("Devices & Insulin Type", isOn: $includeDevices) + toggleRow("AGP Chart", isOn: $includeAGP) + toggleRow("Daily Breakdowns", isOn: $includeDailyBreakdown) + } + } + + sectionCard("Report Formatting", icon: "paintpalette.fill", color: .purple) { + VStack(spacing: 18) { + Picker("Units", selection: $units) { + ForEach(unitOptions, id: \.self) { Text($0) } + } + .pickerStyle(.segmented) + + HStack { + Text("Theme Color") + .foregroundColor(.secondary) + Spacer() + Circle() + .fill(pickedColor) + .frame(width: 24, height: 24) + .overlay(Circle().stroke(Color.secondary.opacity(0.3), lineWidth: 1)) + if #available(iOS 16.0, *) { + ColorPicker("", selection: $pickedColor, supportsOpacity: false) + .labelsHidden() + .onChange(of: pickedColor) { newVal in + accentColorHex = newVal.toHex() ?? "#23A0AC" + } + } + } + } + } + + if let err = errorMessage { + Text(err) + .font(.subheadline) + .foregroundColor(.white) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 16).fill(Color.red.opacity(0.85))) + } + + Button(action: generate) { + HStack { + Spacer() + if isGenerating { + ProgressView().padding(.trailing, 8) + Text("Generating Report…").fontWeight(.semibold) + } else { + Image(systemName: "doc.text.fill").padding(.trailing, 6) + Text("Create PDF").fontWeight(.semibold) + } + Spacer() + } + .padding() + .background(RoundedRectangle(cornerRadius: 18).fill(Color.teal)) + .foregroundColor(.white) + } + .disabled(isGenerating) + .opacity(isGenerating ? 0.7 : 1) + } + .padding() + } + .background(Color(UIColor.systemGroupedBackground).ignoresSafeArea()) + .navigationTitle("Endo Report") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + } + } + .sheet(isPresented: $showShareSheet) { + if let url = reportURL { ShareSheet(items: [url]) } + } + .onAppear { + pickedColor = Color(hex: accentColorHex) ?? .teal + } + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + } + + private enum TherapyInputMode: String, CaseIterable, Identifiable { + case simple = "Simple" + case schedule = "Schedule" + + var id: String { rawValue } + } + + // MARK: - Helpers + + @ViewBuilder + private func row(_ label: String, placeholder: String, text: Binding, + keyboard: UIKeyboardType = .default) -> some View + { + HStack(alignment: .top, spacing: 12) { + Text(label) + .foregroundColor(.secondary) + .font(.subheadline) + .frame(width: 110, alignment: .leading) + TextField(placeholder, text: text) + .keyboardType(keyboard) + .padding(12) + .background(RoundedRectangle(cornerRadius: 14).fill(Color(UIColor.systemGray6))) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(Color.secondary.opacity(0.16), lineWidth: 1) + ) + } + .padding(.vertical, 2) + } + + @ViewBuilder + private func therapySettingRow(_ label: String, icon: String, text: Binding, placeholder: String, help: String, keyboard: UIKeyboardType = .default) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(Color(UIColor.systemGray5)) + .frame(width: 30, height: 30) + Image(systemName: icon) + .foregroundColor(.orange) + .font(.system(size: 14, weight: .semibold)) + } + VStack(alignment: .leading, spacing: 4) { + Text(label) + .foregroundColor(.primary) + .font(.subheadline) + Text(help) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + } + ZStack(alignment: .topLeading) { + if text.wrappedValue.isEmpty { + Text(placeholder) + .foregroundColor(.secondary.opacity(0.6)) + .padding(.horizontal, 14) + .padding(.vertical, 12) + } + TextEditor(text: text) + .keyboardType(keyboard) + .padding(10) + .background(RoundedRectangle(cornerRadius: 14).fill(Color(UIColor.systemGray6))) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(Color.secondary.opacity(0.16), lineWidth: 1) + ) + .frame(minHeight: 100) + } + } + .padding(.vertical, 3) + } + + @ViewBuilder + private func therapySchedulePreview(for schedule: String, title: String, accent: Color) -> some View { + let points = parseSchedule(schedule) + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(title) + .font(.subheadline) + .fontWeight(.semibold) + Spacer() + if points.count > 1 { + Text("Min: \(formatted(points.map { $0.value }.min() ?? 0))") + .font(.caption) + .foregroundColor(.secondary) + } + } + + if points.count > 1 { + scheduleGraph(points: points, accent: accent) + HStack { + Text("00:00") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text("24:00") + .font(.caption) + .foregroundColor(.secondary) + } + } else { + Text("Enter a schedule like 00:00 = 0.8 to visualize the trend.") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 18).fill(Color(UIColor.systemGray6))) + } + + private func parseSchedule(_ input: String) -> [(hour: Double, value: Double)] { + let lines = input + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + + var points: [(Double, Double)] = [] + for line in lines { + let parts = line.split(separator: "=", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) } + guard parts.count == 2, + let value = Double(parts[1].replacingOccurrences(of: ",", with: ".")) + else { + continue + } + + let timeParts = parts[0].split(separator: ":").map { String($0) } + guard timeParts.count == 2, + let hour = Double(timeParts[0]), + let minute = Double(timeParts[1]) + else { + continue + } + let hourValue = max(0, min(24, hour + minute / 60.0)) + points.append((hourValue, value)) + } + + let sorted = points.sorted { $0.0 < $1.0 } + guard let first = sorted.first else { return [] } + + var result = sorted + if sorted.count == 1 { + result = [(0, first.1), (24, first.1)] + } else { + if first.0 > 0 { + result.insert((0, first.1), at: 0) + } + if let last = result.last, last.0 < 24 { + result.append((24, last.1)) + } + } + return result + } + + private func scheduleGraph(points: [(hour: Double, value: Double)], accent: Color) -> some View { + GeometryReader { proxy in + let minValue = points.map { $0.value }.min() ?? 0 + let maxValue = points.map { $0.value }.max() ?? 1 + let range = max(maxValue - minValue, 0.1) + + // Stepped Area Fill + Path { path in + guard let first = points.first else { return } + path.move(to: CGPoint(x: proxy.size.width * CGFloat(first.hour / 24), y: proxy.size.height)) + + for i in 0 ..< points.count { + let x = proxy.size.width * CGFloat(points[i].hour / 24) + let y = proxy.size.height * (1 - CGFloat((points[i].value - minValue) / range)) + if i > 0 { + let prevY = proxy.size.height * (1 - CGFloat((points[i - 1].value - minValue) / range)) + path.addLine(to: CGPoint(x: x, y: prevY)) + } + path.addLine(to: CGPoint(x: x, y: y)) + } + + if let last = points.last { + path.addLine(to: CGPoint(x: proxy.size.width * CGFloat(last.hour / 24), y: proxy.size.height)) + } + path.closeSubpath() + } + .fill(accent.opacity(0.15)) + + // Stepped Line + Path { path in + for index in points.indices { + let point = points[index] + let x = proxy.size.width * CGFloat(point.hour / 24) + let y = proxy.size.height * (1 - CGFloat((point.value - minValue) / range)) + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + let prevPoint = points[index - 1] + let prevY = proxy.size.height * (1 - CGFloat((prevPoint.value - minValue) / range)) + path.addLine(to: CGPoint(x: x, y: prevY)) + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke(accent, lineWidth: 2) + + ForEach(points.indices, id: \.self) { index in + let point = points[index] + let x = proxy.size.width * CGFloat(point.hour / 24) + let y = proxy.size.height * (1 - CGFloat((point.value - minValue) / range)) + Circle() + .fill(accent) + .frame(width: 8, height: 8) + .position(x: x, y: y) + } + } + .frame(height: 140) + } + + private func formatted(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.maximumFractionDigits = 2 + formatter.minimumFractionDigits = 0 + return formatter.string(from: NSNumber(value: value)) ?? "0" + } + + @ViewBuilder + private func settingCard(_ title: String, icon: String, color: Color, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(color.opacity(0.18)) + .frame(width: 28, height: 28) + Image(systemName: icon) + .foregroundColor(color) + .font(.system(size: 13, weight: .semibold)) + } + Text(title) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.primary) + Spacer() + } + content() + } + .padding(12) + .background(Color(UIColor.systemBackground).overlay(color.opacity(0.1))) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(Color.secondary.opacity(0.12), lineWidth: 1) + ) + } + + private func sectionCard(_ title: String, icon: String, color: Color, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 12) { + sectionLabel(title, icon: icon, color: color) + content() + } + .padding(14) + .background(Color(UIColor.systemBackground).overlay(color.opacity(0.1))) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .shadow(color: Color.black.opacity(0.08), radius: 24, x: 0, y: 10) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + @ViewBuilder + private func toggleRow(_ title: String, isOn: Binding) -> some View { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(isOn.wrappedValue ? Color.teal.opacity(0.18) : Color.secondary.opacity(0.12)) + .frame(width: 30, height: 30) + Image(systemName: toggleIcon(for: title)) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(isOn.wrappedValue ? .teal : .secondary) + } + Toggle(isOn: isOn) { + Text(title) + .font(.subheadline) + .foregroundColor(.primary) + } + .toggleStyle(SwitchToggleStyle(tint: .teal)) + } + .padding(10) + .background(RoundedRectangle(cornerRadius: 18).fill(Color(UIColor.systemGray6))) + } + + private func toggleIcon(for title: String) -> String { + let lower = title.lowercased() + if lower.contains("glucose") { return "waveform.path.ecg" } + if lower.contains("insulin") { return "drop.fill" } + if lower.contains("nutrition") { return "fork.knife" } + if lower.contains("therapy") { return "heart.text.square" } + if lower.contains("devices") { return "iphone" } + if lower.contains("agp") { return "chart.bar.doc.horizontal" } + if lower.contains("daily") { return "calendar" } + return "circle.grid.2x2" + } + + private func sectionLabel(_ title: String, icon: String, color: Color) -> some View { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(color.opacity(0.18)) + .frame(width: 32, height: 32) + Image(systemName: icon) + .foregroundColor(color) + .font(.system(size: 14, weight: .semibold)) + } + Text(title) + .font(.headline) + .foregroundColor(.primary) + Spacer() + } + .padding(.vertical, 2) + } + + // MARK: - Presets + + private struct Preset { let label: String; let start: Date; let end: Date } + private var presets: [Preset] { + return [ + Preset(label: "3d", start: StatsDateRange.lastComplete(days: 3).start, end: StatsDateRange.lastComplete(days: 3).end), + Preset(label: "7d", start: StatsDateRange.lastComplete(days: 7).start, end: StatsDateRange.lastComplete(days: 7).end), + Preset(label: "14d", start: StatsDateRange.lastComplete(days: 14).start, end: StatsDateRange.lastComplete(days: 14).end), + Preset(label: "30d", start: StatsDateRange.lastComplete(days: 30).start, end: StatsDateRange.lastComplete(days: 30).end), + Preset(label: "90d", start: StatsDateRange.lastComplete(days: 90).start, end: StatsDateRange.lastComplete(days: 90).end), + ] + } + + private func isActive(_ p: Preset) -> Bool { + Calendar.current.isDate(p.start, inSameDayAs: startDate) && + Calendar.current.isDate(p.end, inSameDayAs: endDate) + } + + // MARK: - Fetch from Nightscout + + private func fetchFromNightscout() { + fetchSuccess = false + profileFetcher.fetch { settings in + guard let s = settings else { return } + carbRatio = s.carbRatio + isf = s.isf + basalRate = s.basalRate + if !s.targetLow.isEmpty && !s.targetHigh.isEmpty { + targetGlucose = "\(s.targetLow)–\(s.targetHigh)" + } else { + targetGlucose = s.targetLow.isEmpty ? s.targetHigh : s.targetLow + } + units = s.units + fetchSuccess = true + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + fetchSuccess = false + } + } + } + + // MARK: - Generate + + private func generate() { + errorMessage = nil + isGenerating = true + + // Strict boundary generation so we capture all 24 hours of start and end days + let cal = Calendar.current + let realStart = cal.startOfDay(for: startDate) + var endComps = DateComponents() + endComps.day = 1 + endComps.second = -1 + let realEnd = cal.date(byAdding: endComps, to: cal.startOfDay(for: endDate)) ?? endDate + + dataService.updateDateRange(start: realStart, end: realEnd) + + dataService.ensureDataAvailable(onProgress: {}) { + DispatchQueue.global(qos: .userInitiated).async { + do { + let config = EndoReportConfig( + patientName: patientName, + dateOfBirth: dateOfBirth, + diagnosisDate: diagnosisDate, + providerName: providerName, + insulinType: insulinType, + aidSystem: aidSystem == "Other" ? customAidSystem : aidSystem, + pumpDevice: pumpDevice, + cgmDevice: cgmDevice, + carbRatio: carbRatio, + isf: isf, + basalRate: basalRate, + targetGlucose: targetGlucose, + units: units, + accentColorHex: accentColorHex, + notes: notes, + includeGlucoseSummary: includeGlucoseSummary, + includeInsulin: includeInsulin, + includeNutrition: includeNutrition, + includeTherapySettings: includeTherapySettings, + includeDevices: includeDevices, + includeAGP: includeAGP, + includeDailyBreakdown: includeDailyBreakdown, + includeFatProtein: includeFatProtein, + startDate: realStart, + endDate: realEnd + ) + let url = try EndoReportGenerator.generate(config: config, dataService: dataService) + DispatchQueue.main.async { + isGenerating = false + reportURL = url + showShareSheet = true + } + } catch { + DispatchQueue.main.async { + isGenerating = false + errorMessage = error.localizedDescription + } + } + } + } + } +} + +// MARK: - Share sheet + +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + func makeUIViewController(context _: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_: UIActivityViewController, context _: Context) {} +} + +// MARK: - Color extensions + +extension Color { + init?(hex: String) { + var h = hex.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "#", with: "") + if h.count == 6 { h += "FF" } + guard h.count == 8, let val = UInt64(h, radix: 16) else { return nil } + self.init( + red: Double((val >> 24) & 0xFF) / 255, + green: Double((val >> 16) & 0xFF) / 255, + blue: Double((val >> 8) & 0xFF) / 255, + opacity: Double(val & 0xFF) / 255 + ) + } + + func toHex() -> String? { + guard let c = UIColor(self).cgColor.components, c.count >= 3 else { return nil } + return String(format: "#%02X%02X%02X", + Int(c[0] * 255), Int(c[1] * 255), Int(c[2] * 255)) + } +} + +extension UIColor { + convenience init?(hex: String) { + var h = hex.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "#", with: "") + if h.count == 6 { h += "FF" } + guard h.count == 8, let val = UInt64(h, radix: 16) else { return nil } + self.init( + red: CGFloat((val >> 24) & 0xFF) / 255, + green: CGFloat((val >> 16) & 0xFF) / 255, + blue: CGFloat((val >> 8) & 0xFF) / 255, + alpha: CGFloat(val & 0xFF) / 255 + ) + } +} + +// MARK: - NightscoutProfileFetcher + +class NightscoutProfileFetcher: ObservableObject { + @Published var isFetching = false + @Published var error: String? + @Published var success = false + + struct FetchedSettings { + let carbRatio: String + let isf: String + let basalRate: String + let targetLow: String + let targetHigh: String + let units: String + } + + func fetch(completion: @escaping (FetchedSettings?) -> Void) { + isFetching = true + error = nil + success = false + + NightscoutUtils.executeRequest( + eventType: .profile, + parameters: [:] + ) { [weak self] (result: Result) in + DispatchQueue.main.async { + guard let self else { return } + self.isFetching = false + + switch result { + case let .failure(err): + self.error = err.localizedDescription + completion(nil) + + case let .success(profile): + let store = profile.store[profile.defaultProfile] + ?? profile.store["default"] + ?? profile.store["Default"] + ?? profile.store.values.first + + guard let s = store else { + self.error = "No profile store found in Nightscout response." + completion(nil) + return + } + + let isMMOL = s.units.lowercased().contains("mmol") + + func fmtValue(_ value: Double) -> String { + if value == floor(value) { + return String(format: "%.0f", value) + } + let raw = String(format: "%.2f", value) + return raw.replacingOccurrences(of: "\\.?0+$", with: "", options: .regularExpression) + } + + func fmtSchedule(_ entries: [T], + value: (T) -> Double, + time: (T) -> String) -> String + { + if entries.count == 1 { + return fmtValue(value(entries[0])) + } + // Output joined by newlines so it populates the multi-line UI cleanly + return entries.map { + "\(time($0)) = \(fmtValue(value($0)))" + }.joined(separator: "\n") + } + + let cr = fmtSchedule(s.carbratio, value: { $0.value }, time: { $0.time }) + let isf = fmtSchedule(s.sens, value: { $0.value }, time: { $0.time }) + let bas = fmtSchedule(s.basal, value: { $0.value }, time: { $0.time }) + + let targetLow = s.target_low?.first.map { String(format: isMMOL ? "%.1f" : "%.0f", $0.value) } ?? "" + let targetHigh = s.target_high?.first.map { String(format: isMMOL ? "%.1f" : "%.0f", $0.value) } ?? "" + + self.success = true + completion(FetchedSettings( + carbRatio: cr, + isf: isf, + basalRate: bas, + targetLow: targetLow, + targetHigh: targetHigh, + units: isMMOL ? "mmol/L" : "mg/dL" + )) + } + } + } + } +} From 952e141180ab92dc3209d841b573042458632685 Mon Sep 17 00:00:00 2001 From: greyghost99 <164137251+greyghost99@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:55:49 -0700 Subject: [PATCH 2/5] Fix Nightscout profile auto-fill decoder for Trio/missing fields --- LoopFollow/Stats/EndoReportView.swift | 186 +++++++++++++++++++------- 1 file changed, 138 insertions(+), 48 deletions(-) diff --git a/LoopFollow/Stats/EndoReportView.swift b/LoopFollow/Stats/EndoReportView.swift index 75b17d9d2..9481257ed 100644 --- a/LoopFollow/Stats/EndoReportView.swift +++ b/LoopFollow/Stats/EndoReportView.swift @@ -836,77 +836,167 @@ class NightscoutProfileFetcher: ObservableObject { let units: String } + // Lenient local structs — all fields optional to survive any Nightscout variant + private struct LenientProfile: Decodable { + let defaultProfile: String? + let units: String? + let store: [String: LenientStore]? + } + + private struct LenientStore: Decodable { + let units: String? + let basal: [Entry]? + let sens: [Entry]? + let carbratio: [Entry]? + let target_low: [Entry]? + let target_high: [Entry]? + + struct Entry: Decodable { + let value: Double? + let time: String? + } + } + func fetch(completion: @escaping (FetchedSettings?) -> Void) { isFetching = true error = nil success = false - NightscoutUtils.executeRequest( - eventType: .profile, + let baseURL = Storage.shared.url.value + let token = Storage.shared.token.value + + guard let url = NightscoutUtils.constructURL( + baseURL: baseURL, + token: token, + endpoint: "/api/v1/profile/current.json", parameters: [:] - ) { [weak self] (result: Result) in + ) else { + DispatchQueue.main.async { + self.isFetching = false + self.error = "Could not construct Nightscout URL. Check your site address in settings." + } + completion(nil) + return + } + + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + + URLSession.shared.dataTask(with: request) { [weak self] data, _, networkError in DispatchQueue.main.async { guard let self else { return } self.isFetching = false - switch result { - case let .failure(err): - self.error = err.localizedDescription + if let networkError { + self.error = networkError.localizedDescription completion(nil) + return + } - case let .success(profile): - let store = profile.store[profile.defaultProfile] - ?? profile.store["default"] - ?? profile.store["Default"] - ?? profile.store.values.first + guard let data, !data.isEmpty else { + self.error = "Empty response from Nightscout. Check your URL and token." + completion(nil) + return + } - guard let s = store else { - self.error = "No profile store found in Nightscout response." + // Try lenient decode first + let decoder = JSONDecoder() + guard let profile = try? decoder.decode(LenientProfile.self, from: data) else { + // Fall back to raw JSON dictionary parsing + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let storeDict = json["store"] as? [String: Any], + let firstStore = storeDict.values.first as? [String: Any] + else { + self.error = "Could not parse Nightscout profile. Tap to retry." completion(nil) return } + self.parseRawStore(firstStore, completion: completion) + return + } - let isMMOL = s.units.lowercased().contains("mmol") + // Pick the right store + let storeName = profile.defaultProfile ?? "default" + let store = profile.store?[storeName] + ?? profile.store?["default"] + ?? profile.store?["Default"] + ?? profile.store?.values.first - func fmtValue(_ value: Double) -> String { - if value == floor(value) { - return String(format: "%.0f", value) - } - let raw = String(format: "%.2f", value) - return raw.replacingOccurrences(of: "\\.?0+$", with: "", options: .regularExpression) - } + guard let s = store else { + self.error = "No profile store found in Nightscout response." + completion(nil) + return + } - func fmtSchedule(_ entries: [T], - value: (T) -> Double, - time: (T) -> String) -> String - { - if entries.count == 1 { - return fmtValue(value(entries[0])) - } - // Output joined by newlines so it populates the multi-line UI cleanly - return entries.map { - "\(time($0)) = \(fmtValue(value($0)))" - }.joined(separator: "\n") - } + let isMMOL = (s.units ?? profile.units ?? "mg/dL").lowercased().contains("mmol") + let result = self.buildSettings(store: s, isMMOL: isMMOL) + self.success = true + completion(result) + } + }.resume() + } - let cr = fmtSchedule(s.carbratio, value: { $0.value }, time: { $0.time }) - let isf = fmtSchedule(s.sens, value: { $0.value }, time: { $0.time }) - let bas = fmtSchedule(s.basal, value: { $0.value }, time: { $0.time }) + // Parse from lenient typed struct + private func buildSettings(store: LenientStore, isMMOL: Bool) -> FetchedSettings { + func fmt(_ v: Double) -> String { + v == floor(v) ? String(format: "%.0f", v) : + String(format: "%.2f", v).replacingOccurrences(of: #"\.?0+$"#, with: "", options: .regularExpression) + } - let targetLow = s.target_low?.first.map { String(format: isMMOL ? "%.1f" : "%.0f", $0.value) } ?? "" - let targetHigh = s.target_high?.first.map { String(format: isMMOL ? "%.1f" : "%.0f", $0.value) } ?? "" + func schedule(_ entries: [LenientStore.Entry]?) -> String { + guard let entries = entries, !entries.isEmpty else { return "" } + let valid = entries.compactMap { e -> (String, Double)? in + guard let v = e.value, let t = e.time else { return nil } + return (t, v) + } + if valid.count == 1 { return fmt(valid[0].1) } + return valid.map { "\($0.0) = \(fmt($0.1))" }.joined(separator: "\n") + } - self.success = true - completion(FetchedSettings( - carbRatio: cr, - isf: isf, - basalRate: bas, - targetLow: targetLow, - targetHigh: targetHigh, - units: isMMOL ? "mmol/L" : "mg/dL" - )) - } + let fmtTarget = isMMOL ? "%.1f" : "%.0f" + let tLow = store.target_low?.first?.value.map { String(format: fmtTarget, $0) } ?? "" + let tHigh = store.target_high?.first?.value.map { String(format: fmtTarget, $0) } ?? "" + + return FetchedSettings( + carbRatio: schedule(store.carbratio), + isf: schedule(store.sens), + basalRate: schedule(store.basal), + targetLow: tLow, + targetHigh: tHigh, + units: isMMOL ? "mmol/L" : "mg/dL" + ) + } + + // Last-resort raw dictionary parser + private func parseRawStore(_ raw: [String: Any], completion: @escaping (FetchedSettings?) -> Void) { + func entries(_ key: String) -> [(String, Double)] { + guard let arr = raw[key] as? [[String: Any]] else { return [] } + return arr.compactMap { e in + guard let v = e["value"] as? Double, let t = e["time"] as? String else { return nil } + return (t, v) } } + func schedule(_ key: String) -> String { + let e = entries(key) + guard !e.isEmpty else { return "" } + if e.count == 1 { return String(format: "%.2g", e[0].1) } + return e.map { "\($0.0) = \(String(format: "%.2g", $0.1))" }.joined(separator: "\n") + } + + let units = (raw["units"] as? String ?? "mg/dL") + let isMMOL = units.lowercased().contains("mmol") + let fmtT = isMMOL ? "%.1f" : "%.0f" + let tLow = (raw["target_low"] as? [[String: Any]])?.first?["value"] as? Double + let tHigh = (raw["target_high"] as? [[String: Any]])?.first?["value"] as? Double + + success = true + completion(FetchedSettings( + carbRatio: schedule("carbratio"), + isf: schedule("sens"), + basalRate: schedule("basal"), + targetLow: tLow.map { String(format: fmtT, $0) } ?? "", + targetHigh: tHigh.map { String(format: fmtT, $0) } ?? "", + units: isMMOL ? "mmol/L" : "mg/dL" + )) } } From 3fda82b9867990a0214b6863240ceadc46276113 Mon Sep 17 00:00:00 2001 From: greyghost99 <164137251+greyghost99@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:43:36 -0700 Subject: [PATCH 3/5] Add time range labels to Glucose by Time of Day strip --- LoopFollow/Stats/EndoReportGenerator.swift | 23 +++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/LoopFollow/Stats/EndoReportGenerator.swift b/LoopFollow/Stats/EndoReportGenerator.swift index 313364df2..20e948246 100644 --- a/LoopFollow/Stats/EndoReportGenerator.swift +++ b/LoopFollow/Stats/EndoReportGenerator.swift @@ -470,23 +470,40 @@ enum EndoReportGenerator { { let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: C_INK] "Glucose by Time of Day (\(cfg.units))".draw(at: CGPoint(x: m, y: y), withAttributes: ha) + let periods = [patterns.night, patterns.earlyAM, patterns.morning, patterns.afternoon, patterns.evening, patterns.late] - let cw = (w - m * 2) / CGFloat(periods.count); let ch: CGFloat = 38; let cy = y + 11 + // Time range labels matching the GlycemicPatterns init hours + let timeRanges = ["00:00–03:00", "03:00–06:00", "06:00–12:00", + "12:00–17:00", "17:00–21:00", "21:00–24:00"] + + let cw = (w - m * 2) / CGFloat(periods.count) + let ch: CGFloat = 48 // taller to fit 3 rows: value + label + time range + let cy = y + 11 + for (i, p) in periods.enumerated() { let cx = m + CGFloat(i) * cw let rr = CGRect(x: cx, y: cy, width: cw - 2, height: ch) ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(rr) ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(rr) + + let timeRange = timeRanges[i] + let ta: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: C_SLATE] + let tsz = (timeRange as NSString).size(withAttributes: ta) + (timeRange as NSString).draw(at: CGPoint(x: cx + (cw - 2 - tsz.width) / 2, y: cy + 3), withAttributes: ta) + guard p.count > 0 else { continue } + let disp = cfg.fmtBG(p.avg) let vc: UIColor = p.avg < 70 ? C_LOW : p.avg < 140 ? accent(cfg) : p.avg < 180 ? C_INK : C_HIGH let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 13), .foregroundColor: vc] let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + let vsz = (disp as NSString).size(withAttributes: va) let lsz = (p.label as NSString).size(withAttributes: la) - (disp as NSString).draw(at: CGPoint(x: cx + (cw - 2 - vsz.width) / 2, y: cy + 5), withAttributes: va) - (p.label as NSString).draw(at: CGPoint(x: cx + (cw - 2 - lsz.width) / 2, y: cy + 25), withAttributes: la) + + (disp as NSString).draw(at: CGPoint(x: cx + (cw - 2 - vsz.width) / 2, y: cy + 13), withAttributes: va) + (p.label as NSString).draw(at: CGPoint(x: cx + (cw - 2 - lsz.width) / 2, y: cy + 29), withAttributes: la) } return cy + ch + 2 } From 9ff1cd5ad0347748953b08d1e0085e6171a356b2 Mon Sep 17 00:00:00 2001 From: greyghost99 <164137251+greyghost99@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:30:13 -0700 Subject: [PATCH 4/5] Show all TIR zones in bar and fix time-of-day value centering --- LoopFollow/Stats/EndoReportGenerator.swift | 1581 ++++++++------------ 1 file changed, 649 insertions(+), 932 deletions(-) diff --git a/LoopFollow/Stats/EndoReportGenerator.swift b/LoopFollow/Stats/EndoReportGenerator.swift index 20e948246..6ad1566b6 100644 --- a/LoopFollow/Stats/EndoReportGenerator.swift +++ b/LoopFollow/Stats/EndoReportGenerator.swift @@ -1,1054 +1,771 @@ // LoopFollow // EndoReportGenerator.swift +// +// Generates a PDF endo report by: +// 1. Reusing the existing AGPCalculator, TIRCalculator, and StatsDataService +// 2. Snapshotting the existing AGPGraphView and TIRGraphView into images +// 3. Assembling a multi-page PDF with PDFKit import PDFKit +import SwiftUI import UIKit -// MARK: - Config - -struct EndoReportConfig { - let patientName: String - let dateOfBirth: String - let diagnosisDate: String - let providerName: String - let insulinType: String - let aidSystem: String - let pumpDevice: String - let cgmDevice: String - let carbRatio: String - let isf: String - let basalRate: String - let targetGlucose: String - let units: String // "mg/dL" or "mmol/L" - let accentColorHex: String - let notes: String - - // Toggles - let includeGlucoseSummary: Bool - let includeInsulin: Bool - let includeNutrition: Bool - let includeTherapySettings: Bool - let includeDevices: Bool - let includeAGP: Bool - let includeDailyBreakdown: Bool - let includeFatProtein: Bool - - let startDate: Date - let endDate: Date - - var accentColor: UIColor { - UIColor(hex: accentColorHex) ?? UIColor(red: 0.137, green: 0.624, blue: 0.675, alpha: 1) - } +enum EndoReportGenerator { - var isMMOL: Bool { units == "mmol/L" } - func convert(_ mgdl: Double) -> Double { isMMOL ? mgdl * 0.0555 : mgdl } - func fmtBG(_ mgdl: Double) -> String { - isMMOL ? String(format: "%.1f", mgdl * 0.0555) : String(format: "%.0f", mgdl) - } -} + // MARK: - Public entry point -// MARK: - Generator + /// Generates a PDF and returns the file URL, or throws on failure. + static func generate( + patientName: String, + dateOfBirth: String, + providerName: String, + startDate: Date, + endDate: Date, + dataService: StatsDataService + ) throws -> URL { -enum EndoReportGenerator { - enum ReportError: LocalizedError { - case noData - var errorDescription: String? { "No CGM data available for the selected date range." } - } + let bgData = dataService.getBGData() + guard !bgData.isEmpty else { + throw ReportError.noData + } - static func generate(config: EndoReportConfig, dataService: StatsDataService) throws -> URL { - let bgData = dataService.getBGData() - guard !bgData.isEmpty else { throw ReportError.noData } - - // Use the existing ViewModels for calculations - let agpVM = AGPViewModel(dataService: dataService) - agpVM.calculateAGP() - - let stats = ReportStats(bgData: bgData, dataService: dataService) - let patterns = TimePatterns(bgData: bgData) - let boluses = dataService.getBolusData() - let smbs = dataService.getSMBData() - let carbs = dataService.getCarbData() - let basals = dataService.getBasalData() - let basalProfile = dataService.getBasalProfile() // Get basal profile here - let simpleVM = SimpleStatsViewModel(dataService: dataService) - simpleVM.calculateStats() - - let pageRect = CGRect(origin: .zero, size: CGSize(width: 612, height: 792)) + let agpData = AGPCalculator.calculate(bgData: bgData) + let tirData = TIRCalculator.calculate(bgData: bgData) + let stats = SimpleStats(bgData: bgData, dataService: dataService) + + let pageRect = CGRect(origin: .zero, size: CGSize(width: 612, height: 792)) // US Letter let renderer = UIGraphicsPDFRenderer(bounds: pageRect) + let url = FileManager.default.temporaryDirectory .appendingPathComponent("EndoReport_\(Int(Date().timeIntervalSince1970)).pdf") - let dailyData = groupByDay(bgData: bgData, boluses: boluses, smbs: smbs, basals: basals, carbs: carbs) - .sorted { $0.key > $1.key } - let data = renderer.pdfData { ctx in - // Page 1 — Summary + // ── Page 1: Summary + AGP ────────────────────────────────────── ctx.beginPage() - drawSummaryPage(ctx: ctx.cgContext, r: pageRect, cfg: config, - bgData: bgData, agpData: agpVM.agpData, - stats: stats, patterns: patterns, - boluses: boluses, smbs: smbs, carbs: carbs, - simpleVM: simpleVM) - - // Pages 2+ — Daily breakdowns - if config.includeDailyBreakdown && !dailyData.isEmpty { - let rowH: CGFloat = 88 - let rowGap: CGFloat = 6 - let topY: CGFloat = 52 - let botY: CGFloat = 762 - let usable = botY - topY - let perPage = Int((usable + rowGap) / (rowH + rowGap)) - let pages = Int(ceil(Double(dailyData.count) / Double(perPage))) - - for p in 0 ..< pages { - ctx.beginPage() - let pageNum = p + 2 - let headerY = drawDailyPageHeader(ctx: ctx.cgContext, r: pageRect, - cfg: config, page: pageNum, - totalPages: pages + 1) - let slice = Array(dailyData[p * perPage ..< min((p + 1) * perPage, dailyData.count)]) - var y = headerY + 8 - for (day, dayData) in slice { - drawDayRow(ctx: ctx.cgContext, x: 28, y: y, - w: pageRect.width - 56, h: rowH, - day: day, dayData: dayData, cfg: config, basalProfile: basalProfile) - y += rowH + rowGap - } - drawFooter(ctx: ctx.cgContext, r: pageRect, cfg: config, - stats: stats, page: pageNum) - } + var cursor = drawHeader( + ctx: ctx.cgContext, + pageRect: pageRect, + patientName: patientName, + dateOfBirth: dateOfBirth, + providerName: providerName, + startDate: startDate, + endDate: endDate + ) + + cursor = drawSectionTitle("Key Metrics", y: cursor, in: pageRect, ctx: ctx.cgContext) + cursor = drawKeyMetrics(stats: stats, y: cursor, in: pageRect, ctx: ctx.cgContext) + + cursor = drawSectionTitle("Time in Range", y: cursor, in: pageRect, ctx: ctx.cgContext) + cursor = drawTIRBar(tirData: tirData, y: cursor, in: pageRect, ctx: ctx.cgContext) + cursor = drawTIRTable(tirData: tirData, y: cursor, in: pageRect, ctx: ctx.cgContext) + + cursor = drawSectionTitle("Ambulatory Glucose Profile (AGP)", y: cursor, in: pageRect, ctx: ctx.cgContext) + cursor = drawAGPChart(agpData: agpData, y: cursor, in: pageRect, ctx: ctx.cgContext) + drawFooter(ctx: ctx.cgContext, pageRect: pageRect, page: 1) + + // ── Page 2: Daily stats + Insulin/Carbs ─────────────────────── + ctx.beginPage() + var cursor2 = drawPageContinuationHeader(ctx: ctx.cgContext, pageRect: pageRect, + patientName: patientName, + startDate: startDate, endDate: endDate) + + cursor2 = drawSectionTitle("Daily Glucose Summary", y: cursor2, in: pageRect, ctx: ctx.cgContext) + cursor2 = drawDailyTable(bgData: bgData, y: cursor2, in: pageRect, ctx: ctx.cgContext) + + // Insulin & carbs if available + let boluses = dataService.getBolusData() + let smbs = dataService.getSMBData() + let carbs = dataService.getCarbData() + if !boluses.isEmpty || !smbs.isEmpty || !carbs.isEmpty { + cursor2 = drawSectionTitle("Insulin & Carbohydrate Summary", + y: cursor2, in: pageRect, ctx: ctx.cgContext) + cursor2 = drawInsulinCarbSummary(boluses: boluses, smbs: smbs, carbs: carbs, + stats: stats, y: cursor2, + in: pageRect, ctx: ctx.cgContext) } + + drawFooter(ctx: ctx.cgContext, pageRect: pageRect, page: 2) } + try data.write(to: url) return url } - // MARK: - Data models + // MARK: - Errors - struct ReportStats { - let avg, stdDev, cv, eA1C, minBG, maxBG, sensorPct, tir, tightTIR, days: Double - let veryLow, low, inRange, high, veryHigh: Double - let readingCount: Int - - init(bgData: [ShareGlucoseData], dataService: StatsDataService) { - let v = bgData.map { Double($0.sgv) }; let n = Double(v.count) - let m = v.reduce(0,+) / n - let variance = v.map { ($0 - m) * ($0 - m) }.reduce(0,+) / n - - avg = m; stdDev = sqrt(variance); cv = stdDev / m * 100; eA1C = (m + 46.7) / 28.7 - minBG = v.min() ?? 0; maxBG = v.max() ?? 0; readingCount = v.count - days = Swift.max(dataService.endDate.timeIntervalSince1970 - dataService.startDate.timeIntervalSince1970, 86400) / 86400 - sensorPct = Swift.min(Double(v.count) / (days * 288) * 100, 100) - - // Calculate TIR Buckets - let vLowCount = Double(v.filter { $0 < 54 }.count) - let lowCount = Double(v.filter { $0 >= 54 && $0 < 70 }.count) - let inRangeCount = Double(v.filter { $0 >= 70 && $0 <= 180 }.count) - let highCount = Double(v.filter { $0 > 180 && $0 <= 250 }.count) - let vHighCount = Double(v.filter { $0 > 250 }.count) - - veryLow = (vLowCount / n) * 100 - low = (lowCount / n) * 100 - inRange = (inRangeCount / n) * 100 - high = (highCount / n) * 100 - veryHigh = (vHighCount / n) * 100 - tir = inRange - tightTIR = Double(v.filter { $0 >= 70 && $0 <= 140 }.count) / n * 100 - } - } - - struct TimePatterns { - struct Period { let label: String; let avg: Double; let count: Int } - let night, earlyAM, morning, afternoon, evening, late: Period - init(bgData: [ShareGlucoseData]) { - func p(_ l: String, _ s: Int, _ e: Int) -> Period { - let cal = dateTimeUtils.displayCalendar() - let r = bgData.filter { let h = cal.component(.hour, from: Date(timeIntervalSince1970: $0.date)); return h >= s && h < e } - return Period(label: l, avg: r.isEmpty ? 0 : r.map { Double($0.sgv) }.reduce(0,+) / Double(r.count), count: r.count) + enum ReportError: LocalizedError { + case noData + var errorDescription: String? { + switch self { + case .noData: return "No CGM data available for the selected date range." } - night = p("Night", 0, 3); earlyAM = p("Early AM", 3, 6); morning = p("Morning", 6, 12) - afternoon = p("Afternoon", 12, 17); evening = p("Evening", 17, 21); late = p("Late", 21, 24) } } - struct DayData { - let bg: [ShareGlucoseData] - let bolus: [MainViewController.bolusGraphStruct] - let smb: [MainViewController.bolusGraphStruct] - let basal: [MainViewController.basalGraphStruct] - let carbs: [MainViewController.carbGraphStruct] - } - - private static func groupByDay( - bgData: [ShareGlucoseData], - boluses: [MainViewController.bolusGraphStruct], - smbs: [MainViewController.bolusGraphStruct], - basals: [MainViewController.basalGraphStruct], - carbs: [MainViewController.carbGraphStruct] - ) -> [String: DayData] { - let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" - var bg: [String: [ShareGlucoseData]] = [:] - var bo: [String: [MainViewController.bolusGraphStruct]] = [:] - var sm: [String: [MainViewController.bolusGraphStruct]] = [:] - var ba: [String: [MainViewController.basalGraphStruct]] = [:] - var ca: [String: [MainViewController.carbGraphStruct]] = [:] - for r in bgData { - let k = df.string(from: Date(timeIntervalSince1970: r.date)); bg[k, default: []].append(r) - } - for r in boluses { - let k = df.string(from: Date(timeIntervalSince1970: r.date)); bo[k, default: []].append(r) - } - for r in smbs { - let k = df.string(from: Date(timeIntervalSince1970: r.date)); sm[k, default: []].append(r) - } - for r in basals { - let k = df.string(from: Date(timeIntervalSince1970: r.date)); ba[k, default: []].append(r) - } - for r in carbs { - let k = df.string(from: Date(timeIntervalSince1970: r.date)); ca[k, default: []].append(r) - } - var result: [String: DayData] = [:] - for k in bg.keys { - result[k] = DayData(bg: bg[k]!, bolus: bo[k] ?? [], smb: sm[k] ?? [], basal: ba[k] ?? [], carbs: ca[k] ?? []) - } - return result - } + // MARK: - Computed stats helper - // MARK: - Colors / fonts - - private static func accent(_ cfg: EndoReportConfig) -> UIColor { cfg.accentColor } - private static func accentDark(_ cfg: EndoReportConfig) -> UIColor { - var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - cfg.accentColor.getHue(&h, saturation: &s, brightness: &b, alpha: &a) - return UIColor(hue: h, saturation: s, brightness: b * 0.72, alpha: a) - } - - private static let C_INK = UIColor(red: 0.133, green: 0.157, blue: 0.192, alpha: 1) - private static let C_SLATE = UIColor(red: 0.400, green: 0.440, blue: 0.490, alpha: 1) - private static let C_CLOUD = UIColor(red: 0.960, green: 0.963, blue: 0.970, alpha: 1) - private static let C_BORDER = UIColor(red: 0.870, green: 0.885, blue: 0.905, alpha: 1) - private static let C_WHITE = UIColor.white - private static let C_VLOW = UIColor(red: 0.820, green: 0.180, blue: 0.180, alpha: 1) - private static let C_LOW = UIColor(red: 0.929, green: 0.490, blue: 0.188, alpha: 1) - private static let C_IN = UIColor(red: 0.200, green: 0.670, blue: 0.470, alpha: 1) - private static let C_HIGH = UIColor(red: 0.910, green: 0.740, blue: 0.220, alpha: 1) - private static let C_VHIGH = UIColor(red: 0.800, green: 0.340, blue: 0.340, alpha: 1) - private static let C_BOLUS = UIColor(red: 0.380, green: 0.220, blue: 0.780, alpha: 0.85) - private static let C_SMB = UIColor(red: 0.800, green: 0.200, blue: 0.600, alpha: 0.75) - private static let C_CARB = UIColor(red: 0.150, green: 0.600, blue: 0.150, alpha: 1.0) - private static let C_BASAL = UIColor(red: 0.102, green: 0.451, blue: 0.933, alpha: 0.65) - - private static func bgColor(_ bg: Double) -> UIColor { - switch bg { case ..<54: return C_VLOW; case ..<70: return C_LOW; case ...180: return C_IN; case ...250: return C_HIGH; default: return C_VHIGH } - } - - // MARK: - Page 1: Summary - - private static func drawSummaryPage( - ctx: CGContext, r: CGRect, cfg: EndoReportConfig, - bgData _: [ShareGlucoseData], agpData: [AGPDataPoint], - stats: ReportStats, patterns: TimePatterns, - boluses: [MainViewController.bolusGraphStruct], - smbs: [MainViewController.bolusGraphStruct], - carbs: [MainViewController.carbGraphStruct], - simpleVM: SimpleStatsViewModel - ) { - let m: CGFloat = 24 - var y = drawHero(ctx: ctx, r: r, cfg: cfg, stats: stats) - - if cfg.includeGlucoseSummary { - y = sectionHdr("GLUCOSE SUMMARY", y: y + 2, m: m, w: r.width, cfg: cfg, ctx: ctx) - - let gridW: CGFloat = r.width - m * 2 - 158 - let cw = gridW / 2 - 3; let ch: CGFloat = 36 - let cards: [(String, String, Bool)] = [ - ("TIME IN RANGE (>70%)", String(format: "%.0f%%", stats.tir), true), - ("GMI (TARGET <7%)", String(format: "%.1f%%", stats.eA1C), false), - ("AVERAGE", cfg.fmtBG(stats.avg) + " \(cfg.units)", false), - ("STD DEVIATION", cfg.fmtBG(stats.stdDev), false), - ("CV (TARGET <36%)", String(format: "%.0f%%", stats.cv), false), - ("READINGS", "\(stats.readingCount)", false), - ] - var gy = y + 1 - for (i, c) in cards.enumerated() { - statCard(c.0, val: c.1, x: m + CGFloat(i % 2) * (cw + 6), y: gy + CGFloat(i / 2) * (ch + 4), - w: cw, h: ch, accent: c.2, cfg: cfg, ctx: ctx) - } - drawTIRBar(stats: stats, x: m + gridW + 10, y: y + 1, - w: 148, h: ch * 3 + 7, cfg: cfg, ctx: ctx) - y = gy + CGFloat(3) * (ch + 4) + 1 + struct SimpleStats { + let avg: Double + let stdDev: Double + let cv: Double + let eA1C: Double + let min: Double + let max: Double + let sensorPct: Double + let readingCount: Int - y = timeStrip(patterns: patterns, cfg: cfg, y: y + 1, m: m, w: r.width, ctx: ctx) + init(bgData: [ShareGlucoseData], dataService: StatsDataService) { + let vals = bgData.map { Double($0.sgv) } + let n = Double(vals.count) + let mean = vals.reduce(0, +) / n + let variance = vals.map { ($0 - mean) * ($0 - mean) }.reduce(0, +) / n + avg = mean + stdDev = sqrt(variance) + cv = stdDev / mean * 100 + eA1C = (mean + 46.7) / 28.7 + min = vals.min() ?? 0 + max = vals.max() ?? 0 + readingCount = vals.count + + let days = max(dataService.endDate.timeIntervalSince1970 - dataService.startDate.timeIntervalSince1970, 86400) / 86400 + let expected = days * 288 + sensorPct = Swift.min(Double(vals.count) / expected * 100, 100) } + } - if cfg.includeInsulin && (!boluses.isEmpty || !smbs.isEmpty) { - y = sectionHdr("INSULIN DELIVERY", y: y + 2, m: m, w: r.width, cfg: cfg, ctx: ctx) - y = insulinSection(boluses: boluses, smbs: smbs, simpleVM: simpleVM, - stats: stats, cfg: cfg, y: y + 1, m: m, w: r.width, ctx: ctx) - } + // MARK: - Layout constants - if cfg.includeNutrition && !carbs.isEmpty { - y = sectionHdr("NUTRITION & MEALS", y: y + 2, m: m, w: r.width, cfg: cfg, ctx: ctx) - y = nutritionSection(carbs: carbs, stats: stats, cfg: cfg, y: y + 1, m: m, w: r.width, ctx: ctx) - } + private static let margin: CGFloat = 36 + private static let bodyFont = UIFont.systemFont(ofSize: 9) + private static let labelFont = UIFont.systemFont(ofSize: 8) + private static let boldFont = UIFont.boldSystemFont(ofSize: 9) + private static let titleFont = UIFont.boldSystemFont(ofSize: 11) + private static let sectionFont = UIFont.boldSystemFont(ofSize: 10) - let hasDevice = cfg.includeDevices && (!cfg.pumpDevice.isEmpty || !cfg.cgmDevice.isEmpty || !cfg.insulinType.isEmpty) - let hasSettings = cfg.includeTherapySettings && (!cfg.carbRatio.isEmpty || !cfg.isf.isEmpty || !cfg.basalRate.isEmpty || !cfg.targetGlucose.isEmpty) + private static let colorVeryLow = UIColor(red: 0.957, green: 0.263, blue: 0.212, alpha: 1) + private static let colorLow = UIColor(red: 1.000, green: 0.596, blue: 0.000, alpha: 1) + private static let colorInRange = UIColor(red: 0.298, green: 0.686, blue: 0.314, alpha: 1) + private static let colorHigh = UIColor(red: 1.000, green: 0.757, blue: 0.027, alpha: 1) + private static let colorVeryHigh = UIColor(red: 1.000, green: 0.341, blue: 0.133, alpha: 1) + private static let colorBlue = UIColor(red: 0.102, green: 0.451, blue: 0.933, alpha: 1) + private static let colorDark = UIColor(red: 0.110, green: 0.169, blue: 0.227, alpha: 1) + private static let colorLightGray = UIColor(red: 0.957, green: 0.961, blue: 0.976, alpha: 1) + private static let colorBorder = UIColor(red: 0.867, green: 0.890, blue: 0.925, alpha: 1) - if hasDevice || hasSettings { - y = sectionHdr("SYSTEM & THERAPY SETTINGS", y: y + 2, m: m, w: r.width, cfg: cfg, ctx: ctx) - var gridItems: [(String, String)] = [] - if hasDevice { - if !cfg.pumpDevice.isEmpty { gridItems.append(("Pump", cfg.pumpDevice)) } - if !cfg.cgmDevice.isEmpty { gridItems.append(("CGM", cfg.cgmDevice)) } - if !cfg.insulinType.isEmpty { gridItems.append(("Insulin", cfg.insulinType)) } - } - if hasSettings { - if !cfg.carbRatio.isEmpty { gridItems.append(("CR", cfg.carbRatio)) } - if !cfg.isf.isEmpty { gridItems.append(("ISF", cfg.isf)) } - if !cfg.basalRate.isEmpty { gridItems.append(("Basal", formatBasalRateForDisplay(cfg.basalRate))) } - if !cfg.targetGlucose.isEmpty { gridItems.append(("Target", cfg.targetGlucose)) } - } - y = drawSettingsGrid(gridItems, x: m, y: y + 1, width: r.width - m * 2, cfg: cfg, ctx: ctx) - } + // MARK: - Header / Footer - if !cfg.notes.isEmpty { - y = drawNotesSection(cfg.notes, x: m, y: y + 2, width: r.width - m * 2, cfg: cfg, ctx: ctx) + @discardableResult + private static func drawHeader( + ctx: CGContext, + pageRect: CGRect, + patientName: String, + dateOfBirth: String, + providerName: String, + startDate: Date, + endDate: Date + ) -> CGFloat { + + let headerH: CGFloat = 52 + ctx.setFillColor(colorDark.cgColor) + ctx.fill(CGRect(x: 0, y: 0, width: pageRect.width, height: headerH)) + + let titleAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 14), + .foregroundColor: UIColor.white, + ] + "Continuous Glucose Monitor Report".draw(at: CGPoint(x: margin, y: 10), withAttributes: titleAttrs) + + let df = DateFormatter() + df.dateFormat = "MMM d, yyyy" + let rangeStr = "\(df.string(from: startDate)) – \(df.string(from: endDate))" + let subAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 9), + .foregroundColor: UIColor(white: 0.8, alpha: 1), + ] + let rangeSize = (rangeStr as NSString).size(withAttributes: subAttrs) + (rangeStr as NSString).draw( + at: CGPoint(x: pageRect.width - margin - rangeSize.width, y: 12), + withAttributes: subAttrs + ) + + // Patient info bar + let infoY: CGFloat = 28 + let infoAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 8.5), + .foregroundColor: UIColor(white: 0.75, alpha: 1), + ] + "Patient: \(patientName)".draw(at: CGPoint(x: margin, y: infoY), withAttributes: infoAttrs) + if !dateOfBirth.isEmpty { + "DOB: \(dateOfBirth)".draw(at: CGPoint(x: margin + 180, y: infoY), withAttributes: infoAttrs) } - - if cfg.includeAGP, !agpData.isEmpty { - let agpAvail = r.height - y - 40 - if agpAvail >= 80 { - y = sectionHdr("AMBULATORY GLUCOSE PROFILE", y: y + 6, m: m, w: r.width, cfg: cfg, ctx: ctx) - let agpH = Swift.min(agpAvail - 20, 130) - drawAGP(agpData: agpData, x: m, y: y + 4, w: r.width - m * 2, h: agpH, cfg: cfg, ctx: ctx) - } + if !providerName.isEmpty { + let provStr = "Provider: \(providerName)" + let provSize = (provStr as NSString).size(withAttributes: infoAttrs) + (provStr as NSString).draw( + at: CGPoint(x: pageRect.width - margin - provSize.width, y: infoY), + withAttributes: infoAttrs + ) } - drawFooter(ctx: ctx, r: r, cfg: cfg, stats: stats, page: 1) + return headerH + 12 } - // MARK: - Hero header - @discardableResult - private static func drawHero(ctx: CGContext, r: CGRect, cfg: EndoReportConfig, stats: ReportStats) -> CGFloat { - let h: CGFloat = 90; let ac = accent(cfg); let ad = accentDark(cfg) - ctx.setFillColor(ac.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: h)) - ctx.setFillColor(ad.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: 21)) - - let a1: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8.5), .foregroundColor: C_WHITE.withAlphaComponent(0.8), .kern: 3.0] - "LOOP FOLLOW".draw(at: CGPoint(x: 26, y: 5), withAttributes: a1) - - let a2: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 21), .foregroundColor: C_WHITE] - "Endocrinologist Visit Report".draw(at: CGPoint(x: 26, y: 26), withAttributes: a2) - - let a3: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 9.5), .foregroundColor: C_WHITE.withAlphaComponent(0.82)] - "Automated Insulin Delivery Performance Summary".draw(at: CGPoint(x: 26, y: 52), withAttributes: a3) - - let df = DateFormatter(); df.dateFormat = "MMMM d, yyyy" - let ds = "\(df.string(from: cfg.startDate)) — \(df.string(from: cfg.endDate)) (\(Int(stats.days.rounded())) Days)" - let a4: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 9), .foregroundColor: C_WHITE.withAlphaComponent(0.68)] - ds.draw(at: CGPoint(x: 26, y: 68), withAttributes: a4) - - let a5: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 8.5), .foregroundColor: C_WHITE.withAlphaComponent(0.95)] - var lines: [String] = [] - if !cfg.patientName.isEmpty { lines.append("Patient: \(cfg.patientName)") } - if !cfg.providerName.isEmpty { lines.append("Provider: \(cfg.providerName)") } - if !cfg.dateOfBirth.isEmpty { lines.append("DOB: \(cfg.dateOfBirth)") } - if !cfg.aidSystem.isEmpty { lines.append("AID: \(cfg.aidSystem)") } - if !cfg.diagnosisDate.isEmpty { lines.append("Dx: \(cfg.diagnosisDate)") } - - for (i, l) in lines.enumerated() { - let sz = (l as NSString).size(withAttributes: a5) - (l as NSString).draw(at: CGPoint(x: r.width - 26 - sz.width, y: 24 + CGFloat(i) * 11.5), withAttributes: a5) - } - return h + private static func drawPageContinuationHeader( + ctx: CGContext, pageRect: CGRect, + patientName: String, startDate: Date, endDate: Date + ) -> CGFloat { + let headerH: CGFloat = 32 + ctx.setFillColor(colorDark.cgColor) + ctx.fill(CGRect(x: 0, y: 0, width: pageRect.width, height: headerH)) + + let attrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 11), + .foregroundColor: UIColor.white, + ] + "CGM Report — \(patientName)".draw(at: CGPoint(x: margin, y: 9), withAttributes: attrs) + return headerH + 12 } - // MARK: - Daily page header + private static func drawFooter(ctx: CGContext, pageRect: CGRect, page: Int) { + let footerY = pageRect.height - 28 + ctx.setFillColor(colorLightGray.cgColor) + ctx.fill(CGRect(x: 0, y: footerY, width: pageRect.width, height: 28)) - @discardableResult - private static func drawDailyPageHeader(ctx: CGContext, r: CGRect, cfg: EndoReportConfig, - page: Int, totalPages: Int) -> CGFloat - { - let h: CGFloat = 40; let ac = accent(cfg) - ctx.setFillColor(ac.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: h)) - let a1: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 12), .foregroundColor: C_WHITE] - "Daily Glucose Breakdown".draw(at: CGPoint(x: 28, y: 11), withAttributes: a1) - let sub = "Newest to Oldest • Page \(page) of \(totalPages)" - let a2: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 8), .foregroundColor: C_WHITE.withAlphaComponent(0.75)] - let sz = (sub as NSString).size(withAttributes: a2) - (sub as NSString).draw(at: CGPoint(x: r.width - 28 - sz.width, y: 14), withAttributes: a2) - return h + let attrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 7.5), + .foregroundColor: UIColor.secondaryLabel, + ] + "LoopFollow CGM Report | For clinical use only | Targets 70–180 mg/dL" + .draw(at: CGPoint(x: margin, y: footerY + 8), withAttributes: attrs) + + let pageStr = "Page \(page)" + let pageSize = (pageStr as NSString).size(withAttributes: attrs) + (pageStr as NSString).draw( + at: CGPoint(x: pageRect.width - margin - pageSize.width, y: footerY + 8), + withAttributes: attrs + ) } - // MARK: - Section header + // MARK: - Section title @discardableResult - private static func sectionHdr(_ title: String, y: CGFloat, m: CGFloat, w: CGFloat, - cfg: EndoReportConfig, ctx: CGContext) -> CGFloat - { - ctx.setFillColor(accent(cfg).cgColor) - ctx.fill(CGRect(x: m, y: y, width: 3, height: 14)) - let a: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 9), .foregroundColor: accent(cfg), .kern: 0.6] - (title as NSString).draw(at: CGPoint(x: m + 8, y: y), withAttributes: a) - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) - ctx.move(to: CGPoint(x: m, y: y + 15)); ctx.addLine(to: CGPoint(x: w - m, y: y + 15)); ctx.strokePath() - return y + 16 - } - - // MARK: - Stat card - - private static func statCard(_ label: String, val: String, x: CGFloat, y: CGFloat, - w: CGFloat, h: CGFloat, accent ac: Bool, - cfg: EndoReportConfig, ctx: CGContext) - { - let r = CGRect(x: x, y: y, width: w, height: h) - ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r) - if ac { - ctx.setFillColor(accent(cfg).withAlphaComponent(0.07).cgColor); ctx.fill(r) - ctx.setFillColor(accent(cfg).cgColor); ctx.fill(CGRect(x: x, y: y, width: 3, height: h)) - } - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r) - let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.5] - let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 16), .foregroundColor: ac ? accent(cfg) : C_INK] - (label as NSString).draw(at: CGPoint(x: x + 8, y: y + 4), withAttributes: la) - (val as NSString).draw(at: CGPoint(x: x + 8, y: y + 14), withAttributes: va) - } - - // MARK: - TIR vertical bar - - private static func drawTIRBar(stats: ReportStats, - x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat, - cfg: EndoReportConfig, ctx: CGContext) - { - let r = CGRect(x: x, y: y, width: w, height: h) - ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r) - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r) - - let ta: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: C_SLATE] - "Time in Range".draw(at: CGPoint(x: x + 8, y: y + 6), withAttributes: ta) - - // Shorten bar height to allow text room - let bx = x + 10; let bw: CGFloat = 16; let by = y + 22; let bh = h - 50 - let segs: [(Double, UIColor, String)] = [ - (stats.veryHigh, C_VHIGH, "Very High"), (stats.high, C_HIGH, "High"), - (stats.inRange, C_IN, "In Range"), (stats.low, C_LOW, "Low"), - (stats.veryLow, C_VLOW, "Very Low"), + private static func drawSectionTitle(_ title: String, y: CGFloat, in pageRect: CGRect, ctx: CGContext) -> CGFloat { + let attrs: [NSAttributedString.Key: Any] = [ + .font: sectionFont, + .foregroundColor: colorBlue, ] - var sy = by - - for (pct, clr, _) in segs { - let sh = CGFloat(pct / 100) * bh - if sh > 0 { ctx.setFillColor(clr.cgColor); ctx.fill(CGRect(x: bx, y: sy, width: bw, height: sh)) } - sy += sh - } + (title.uppercased() as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: attrs) - // Draw a stable legend to avoid overlapping text inside a constrained vertical bar. - let legendX = bx + bw + 8 - let legendY = by - let legendSpacing: CGFloat = 12 - for (index, (pct, _, label)) in segs.filter({ $0.0 > 0.0 }).enumerated() { - let ps = String(format: "%.0f%%", pct) - let isTarget = (label == "In Range") - let pa: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: isTarget ? accent(cfg) : C_SLATE] - let textStr = "\(label) \(ps)" - let textY = legendY + CGFloat(index) * legendSpacing - (textStr as NSString).draw(at: CGPoint(x: legendX, y: textY), withAttributes: pa) - } + let lineY = y + 14 + ctx.setStrokeColor(colorBorder.cgColor) + ctx.setLineWidth(0.5) + ctx.move(to: CGPoint(x: margin, y: lineY)) + ctx.addLine(to: CGPoint(x: pageRect.width - margin, y: lineY)) + ctx.strokePath() - let na: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] - "Target: 70-180".draw(at: CGPoint(x: x + 5, y: y + h - 24), withAttributes: na) - "Time in Tight Range: 70-140".draw(at: CGPoint(x: x + 5, y: y + h - 12), withAttributes: na) + return lineY + 8 } - // MARK: - Time-of-day strip + // MARK: - Key metrics cards @discardableResult - private static func timeStrip(patterns: TimePatterns, cfg: EndoReportConfig, - y: CGFloat, m: CGFloat, w: CGFloat, ctx: CGContext) -> CGFloat - { - let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: C_INK] - "Glucose by Time of Day (\(cfg.units))".draw(at: CGPoint(x: m, y: y), withAttributes: ha) - - let periods = [patterns.night, patterns.earlyAM, patterns.morning, - patterns.afternoon, patterns.evening, patterns.late] - // Time range labels matching the GlycemicPatterns init hours - let timeRanges = ["00:00–03:00", "03:00–06:00", "06:00–12:00", - "12:00–17:00", "17:00–21:00", "21:00–24:00"] - - let cw = (w - m * 2) / CGFloat(periods.count) - let ch: CGFloat = 48 // taller to fit 3 rows: value + label + time range - let cy = y + 11 - - for (i, p) in periods.enumerated() { - let cx = m + CGFloat(i) * cw - let rr = CGRect(x: cx, y: cy, width: cw - 2, height: ch) - ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(rr) - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(rr) - - let timeRange = timeRanges[i] - let ta: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: C_SLATE] - let tsz = (timeRange as NSString).size(withAttributes: ta) - (timeRange as NSString).draw(at: CGPoint(x: cx + (cw - 2 - tsz.width) / 2, y: cy + 3), withAttributes: ta) - - guard p.count > 0 else { continue } - - let disp = cfg.fmtBG(p.avg) - let vc: UIColor = p.avg < 70 ? C_LOW : p.avg < 140 ? accent(cfg) : p.avg < 180 ? C_INK : C_HIGH - let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 13), .foregroundColor: vc] - let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] - - let vsz = (disp as NSString).size(withAttributes: va) - let lsz = (p.label as NSString).size(withAttributes: la) - - (disp as NSString).draw(at: CGPoint(x: cx + (cw - 2 - vsz.width) / 2, y: cy + 13), withAttributes: va) - (p.label as NSString).draw(at: CGPoint(x: cx + (cw - 2 - lsz.width) / 2, y: cy + 29), withAttributes: la) - } - return cy + ch + 2 - } + private static func drawKeyMetrics(stats: SimpleStats, y: CGFloat, in pageRect: CGRect, ctx: CGContext) -> CGFloat { + let units = Storage.shared.units.value + let isMMOL = units == "mmol/L" + + func fmtGlucose(_ v: Double) -> String { + isMMOL ? String(format: "%.1f", v * 0.0555) : String(format: "%.0f", v) + } + + let cards: [(String, String, String)] = [ + ("eA1C", String(format: "%.1f%%", stats.eA1C), "Estimated A1C"), + ("TIR", { + // grab from TIR calculator average + let t = TIRCalculator.calculate(bgData: []) // placeholder — we draw this separately + return "—" + }(), "70–180 mg/dL"), + ("Avg Glucose", fmtGlucose(stats.avg), units), + ("CV", String(format: "%.1f%%", stats.cv), "SD \(fmtGlucose(stats.stdDev))"), + ("Sensor Active", String(format: "%.0f%%", stats.sensorPct), "\(stats.readingCount) readings"), + ] - // MARK: - Insulin section + let cardW = (pageRect.width - margin * 2) / CGFloat(cards.count) + let cardH: CGFloat = 52 + let startX = margin + + for (i, card) in cards.enumerated() { + let cardX = startX + CGFloat(i) * cardW + let rect = CGRect(x: cardX, y: y, width: cardW - 4, height: cardH) + + // Background + ctx.setFillColor(colorLightGray.cgColor) + ctx.fill(rect) + ctx.setStrokeColor(colorBorder.cgColor) + ctx.setLineWidth(0.5) + ctx.stroke(rect) + + // Value + let valAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 17), + .foregroundColor: colorDark, + ] + let valSize = (card.1 as NSString).size(withAttributes: valAttrs) + let valX = cardX + (cardW - 4 - valSize.width) / 2 + (card.1 as NSString).draw(at: CGPoint(x: valX, y: y + 8), withAttributes: valAttrs) + + // Label + let lblAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 7.5), + .foregroundColor: UIColor.secondaryLabel, + ] + let lblSize = (card.0 as NSString).size(withAttributes: lblAttrs) + (card.0 as NSString).draw( + at: CGPoint(x: cardX + (cardW - 4 - lblSize.width) / 2, y: y + 28), + withAttributes: lblAttrs + ) - @discardableResult - private static func insulinSection(boluses: [MainViewController.bolusGraphStruct], - smbs: [MainViewController.bolusGraphStruct], - simpleVM: SimpleStatsViewModel, stats _: ReportStats, - cfg: EndoReportConfig, y: CGFloat, m: CGFloat, w: CGFloat, - ctx: CGContext) -> CGFloat - { - let tdd = simpleVM.totalDailyDose ?? 0 - let basalPct = tdd > 0 ? (simpleVM.actualBasal ?? 0) / tdd * 100 : 0 - let bolusPct = tdd > 0 ? (simpleVM.avgBolus ?? 0) / tdd * 100 : 0 - let cards: [(String, String)] = [("AVG TDD", tdd > 0 ? String(format: "%.1fU", tdd) : "—"), - ("BASAL", basalPct > 0 ? String(format: "%.0f%%", basalPct) : "—"), - ("BOLUS", bolusPct > 0 ? String(format: "%.0f%%", bolusPct) : "—")] - let cw = (w - m * 2) / 3 - 4; let ch: CGFloat = 36 - for (i, c) in cards.enumerated() { - let cx = m + CGFloat(i) * (cw + 4) - let r2 = CGRect(x: cx, y: y, width: cw, height: ch) - ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r2) - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r2) - let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.4] - let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 16), .foregroundColor: C_INK] - (c.0 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 4), withAttributes: la) - (c.1 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 14), withAttributes: va) + let subSize = (card.2 as NSString).size(withAttributes: lblAttrs) + (card.2 as NSString).draw( + at: CGPoint(x: cardX + (cardW - 4 - subSize.width) / 2, y: y + 39), + withAttributes: lblAttrs + ) } - let ty = y + ch + 2 - let total = (boluses + smbs).map { $0.value }.reduce(0,+) - let rows: [(String, String)] = [ - ("Correction Boluses", "\(boluses.count)"), - ("SMB / Auto-Corrections", "\(smbs.count)"), - ("Total Bolus Insulin", String(format: "%.1f U", total)), - ("Programmed Basal", simpleVM.programmedBasal != nil ? String(format: "%.2f U/day", simpleVM.programmedBasal!) : "—"), - ("Actual Basal", simpleVM.actualBasal != nil ? String(format: "%.2f U/day", simpleVM.actualBasal!) : "—"), - ] - return metricTable(rows, x: m, y: ty, width: w - m * 2, cfg: cfg, ctx: ctx) + + return y + cardH + 12 } - // MARK: - Nutrition section + // MARK: - TIR bar @discardableResult - private static func nutritionSection(carbs: [MainViewController.carbGraphStruct], - stats: ReportStats, cfg _: EndoReportConfig, - y: CGFloat, m: CGFloat, w: CGFloat, ctx: CGContext) -> CGFloat - { - let total = carbs.map { $0.value }.reduce(0,+) - let cards: [(String, String)] = [ - ("DAILY CARBS", String(format: "%.0fg", total / stats.days)), - ("MEALS LOGGED", "\(carbs.count)"), - ("PER MEAL AVG", String(format: "%.0fg", carbs.isEmpty ? 0 : total / Double(carbs.count))), + private static func drawTIRBar(tirData: [TIRDataPoint], y: CGFloat, in pageRect: CGRect, ctx: CGContext) -> CGFloat { + guard let avg = tirData.first(where: { $0.period == .average }) else { return y } + + let barW = pageRect.width - margin * 2 + let barH: CGFloat = 20 + var x = margin + + let segments: [(CGFloat, UIColor)] = [ + (CGFloat(avg.veryLow / 100) * barW, colorVeryLow), + (CGFloat(avg.low / 100) * barW, colorLow), + (CGFloat(avg.inRange / 100) * barW, colorInRange), + (CGFloat(avg.high / 100) * barW, colorHigh), + (CGFloat(avg.veryHigh / 100) * barW, colorVeryHigh), ] - let cw = (w - m * 2) / 3 - 4; let ch: CGFloat = 36 - for (i, c) in cards.enumerated() { - let cx = m + CGFloat(i) * (cw + 4) - let r2 = CGRect(x: cx, y: y, width: cw, height: ch) - ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r2) - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r2) - let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.4] - let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 16), .foregroundColor: C_INK] - (c.0 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 4), withAttributes: la) - (c.1 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 14), withAttributes: va) - } - return y + ch + 2 - } - // MARK: - Tables - - @discardableResult - private static func metricTable(_ rows: [(String, String)], x: CGFloat, y: CGFloat, - width: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat - { - let tw = width; let hh: CGFloat = 12; let rh: CGFloat = 11; var cy = y - ctx.setFillColor(accent(cfg).withAlphaComponent(0.10).cgColor) - ctx.fill(CGRect(x: x, y: cy, width: tw, height: hh)) - let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg), .kern: 0.4] - "METRIC".draw(at: CGPoint(x: x + 6, y: cy + 1), withAttributes: ha) - "VALUE".draw(at: CGPoint(x: x + tw * 0.58 + 6, y: cy + 1), withAttributes: ha) - cy += hh - for (i, row) in rows.enumerated() { - ctx.setFillColor((i % 2 == 0 ? C_WHITE : C_CLOUD).cgColor) - ctx.fill(CGRect(x: x, y: cy, width: tw, height: rh)) - let ka: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 7), .foregroundColor: C_INK] - let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg)] - (row.0 as NSString).draw(at: CGPoint(x: x + 6, y: cy + 1), withAttributes: ka) - (row.1 as NSString).draw(at: CGPoint(x: x + tw * 0.58 + 6, y: cy + 1), withAttributes: va) - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.3) - ctx.move(to: CGPoint(x: x, y: cy + rh)); ctx.addLine(to: CGPoint(x: x + tw, y: cy + rh)); ctx.strokePath() - cy += rh + for (w, clr) in segments { + if w > 0 { + ctx.setFillColor(clr.cgColor) + ctx.fill(CGRect(x: x, y: y, width: w, height: barH)) + } + x += w + } + + // Labels inside bar + x = margin + let pcts = [avg.veryLow, avg.low, avg.inRange, avg.high, avg.veryHigh] + let clrs = [colorVeryLow, colorLow, colorInRange, colorHigh, colorVeryHigh] + for (i, pct) in pcts.enumerated() { + let w = CGFloat(pct / 100) * barW + if pct >= 5 { + let pctStr = String(format: "%.1f%%", pct) + let attrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 8), + .foregroundColor: UIColor.white, + ] + let sz = (pctStr as NSString).size(withAttributes: attrs) + (pctStr as NSString).draw( + at: CGPoint(x: x + (w - sz.width) / 2, y: y + (barH - sz.height) / 2), + withAttributes: attrs + ) + } + x += w } - return cy + 1 - } - - // Dynamic settings table to handle multi-line text input neatly - @discardableResult - private static func settingsTable(_ rows: [(String, String)], x: CGFloat, y: CGFloat, - width: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat - { - let tw = width - let headerH: CGFloat = 12 - var cy = y - - ctx.setFillColor(accent(cfg).withAlphaComponent(0.10).cgColor) - ctx.fill(CGRect(x: x, y: cy, width: tw, height: headerH)) - let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg), .kern: 0.4] - "THERAPY SETTING & VALUES".draw(at: CGPoint(x: x + 6, y: cy + 3), withAttributes: ha) - cy += headerH - - for (i, row) in rows.enumerated() { - let lines = row.1.components(separatedBy: "\n").filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } - let rh = 11.0 + CGFloat(lines.count) * 10.5 + 4.0 - - ctx.setFillColor((i % 2 == 0 ? C_WHITE : C_CLOUD).cgColor) - ctx.fill(CGRect(x: x, y: cy, width: tw, height: rh)) + // Legend + let legendY = y + barH + 4 + let legendItems: [(String, UIColor)] = [ + ("Very Low <54", colorVeryLow), + ("Low 54-69", colorLow), + ("In Range 70-180", colorInRange), + ("High 181-250", colorHigh), + ("Very High >250", colorVeryHigh), + ] + let itemW = barW / CGFloat(legendItems.count) + for (i, item) in legendItems.enumerated() { + let ix = margin + CGFloat(i) * itemW + ctx.setFillColor(item.1.cgColor) + ctx.fill(CGRect(x: ix, y: legendY, width: 8, height: 8)) + let attrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 7), + .foregroundColor: UIColor.secondaryLabel, + ] + (item.0 as NSString).draw(at: CGPoint(x: ix + 10, y: legendY), withAttributes: attrs) + } - let ka: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 7.5), .foregroundColor: C_SLATE] - let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: accent(cfg)] + return legendY + 16 + } - (row.0 as NSString).draw(at: CGPoint(x: x + 6, y: cy + 3.5), withAttributes: ka) + // MARK: - TIR table - var ly = cy + 12.5 - for line in lines { - (line as NSString).draw(at: CGPoint(x: x + 6, y: ly), withAttributes: va) - ly += 10.5 + @discardableResult + private static func drawTIRTable(tirData: [TIRDataPoint], y: CGFloat, in pageRect: CGRect, ctx: CGContext) -> CGFloat { + let cols: [CGFloat] = [100, 100, 70, 80, 70] + let headers = ["Zone", "Range", "Your %", "ADA Target", "Status"] + let rows: [(String, String, Double, String, Bool)] = [ + ("Very Low", "< 54 mg/dL", tirData.first(where:{$0.period == .average})?.veryLow ?? 0, "< 1%", (tirData.first(where:{$0.period == .average})?.veryLow ?? 99) < 1), + ("Low", "54–69 mg/dL", tirData.first(where:{$0.period == .average})?.low ?? 0, "< 4%", (tirData.first(where:{$0.period == .average})?.low ?? 99) < 4), + ("In Range", "70–180 mg/dL", tirData.first(where:{$0.period == .average})?.inRange ?? 0, "> 70%", (tirData.first(where:{$0.period == .average})?.inRange ?? 0) >= 70), + ("High", "181–250 mg/dL",tirData.first(where:{$0.period == .average})?.high ?? 0, "< 25%", (tirData.first(where:{$0.period == .average})?.high ?? 99) < 25), + ("Very High", "> 250 mg/dL", tirData.first(where:{$0.period == .average})?.veryHigh ?? 0,"< 5%", (tirData.first(where:{$0.period == .average})?.veryHigh ?? 99) < 5), + ] + let rowColors = [colorVeryLow, colorLow, colorInRange, colorHigh, colorVeryHigh] + + let rowH: CGFloat = 16 + var curY = y + var curX = margin + + // Header row + ctx.setFillColor(colorDark.cgColor) + let totalW = cols.reduce(0, +) + ctx.fill(CGRect(x: margin, y: curY, width: totalW, height: rowH)) + for (i, h) in headers.enumerated() { + let attrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 8), + .foregroundColor: UIColor.white, + ] + (h as NSString).draw(at: CGPoint(x: curX + 4, y: curY + 4), withAttributes: attrs) + curX += cols[i] + } + curY += rowH + + // Data rows + for (ri, row) in rows.enumerated() { + ctx.setFillColor((ri % 2 == 0 ? UIColor.white : colorLightGray).cgColor) + ctx.fill(CGRect(x: margin, y: curY, width: totalW, height: rowH)) + + // Zone color swatch in first cell + ctx.setFillColor(rowColors[ri].cgColor) + ctx.fill(CGRect(x: margin, y: curY, width: cols[0], height: rowH)) + + let cells = [row.0, row.1, String(format: "%.1f%%", row.2), row.3, row.4 ? "✓" : "↑"] + curX = margin + for (ci, cell) in cells.enumerated() { + let attrs: [NSAttributedString.Key: Any] = [ + .font: ci == 0 ? UIFont.boldSystemFont(ofSize: 8) : UIFont.systemFont(ofSize: 8), + .foregroundColor: ci == 0 ? UIColor.white : colorDark, + ] + (cell as NSString).draw(at: CGPoint(x: curX + 4, y: curY + 4), withAttributes: attrs) + curX += cols[ci] } - ctx.setStrokeColor(C_BORDER.cgColor) + // Border + ctx.setStrokeColor(colorBorder.cgColor) ctx.setLineWidth(0.3) - ctx.move(to: CGPoint(x: x, y: cy + rh)) - ctx.addLine(to: CGPoint(x: x + tw, y: cy + rh)) - ctx.strokePath() - - cy += rh + ctx.stroke(CGRect(x: margin, y: curY, width: totalW, height: rowH)) + curY += rowH } - return cy + 2 - } - // MARK: - AGP - - private static func drawAGP(agpData: [AGPDataPoint], x: CGFloat, y: CGFloat, - w: CGFloat, h: CGFloat, cfg: EndoReportConfig, ctx: CGContext) - { - guard !agpData.isEmpty else { return } - let lPad: CGFloat = 26; let bPad: CGFloat = 24 - let cw = w - lPad; let ch = h - bPad - let cx = x + lPad; let cy = y - - ctx.setFillColor(UIColor(white: 0.985, alpha: 1).cgColor) - ctx.fill(CGRect(x: cx, y: cy, width: cw, height: ch)) + return curY + 12 + } - let bgMin: CGFloat = 40; let bgRng: CGFloat = 320 - func gy(_ g: Double) -> CGFloat { cy + ch - (CGFloat(g) - bgMin) / bgRng * ch } - func tx(_ mins: Int) -> CGFloat { cx + CGFloat(mins) / (24 * 60) * cw } + // MARK: - AGP chart (drawn natively with Core Graphics) - ctx.setFillColor(C_IN.withAlphaComponent(0.07).cgColor) - ctx.fill(CGRect(x: cx, y: gy(180), width: cw, height: gy(70) - gy(180))) + @discardableResult + private static func drawAGPChart(agpData: [AGPDataPoint], y: CGFloat, in pageRect: CGRect, ctx: CGContext) -> CGFloat { + guard !agpData.isEmpty else { return y } + + let chartW: CGFloat = pageRect.width - margin * 2 + let chartH: CGFloat = 140 + let chartX: CGFloat = margin + let chartY: CGFloat = y + + // Background + ctx.setFillColor(UIColor(white: 0.98, alpha: 1).cgColor) + ctx.fill(CGRect(x: chartX, y: chartY, width: chartW, height: chartH)) + + // Target zones + let yRange: CGFloat = 350 // 40–400 + let yMin: CGFloat = 40 + func glucoseToY(_ g: Double) -> CGFloat { + chartY + chartH - (CGFloat(g) - yMin) / yRange * chartH + } + func timeToX(_ minutes: Int) -> CGFloat { + chartX + CGFloat(minutes) / (24 * 60) * chartW + } + + // Very low zone + ctx.setFillColor(colorVeryLow.withAlphaComponent(0.08).cgColor) + ctx.fill(CGRect(x: chartX, y: glucoseToY(54), width: chartW, height: glucoseToY(40) - glucoseToY(54))) + + // Low zone + ctx.setFillColor(colorLow.withAlphaComponent(0.08).cgColor) + ctx.fill(CGRect(x: chartX, y: glucoseToY(70), width: chartW, height: glucoseToY(54) - glucoseToY(70))) + + // High zone + ctx.setFillColor(colorHigh.withAlphaComponent(0.08).cgColor) + ctx.fill(CGRect(x: chartX, y: glucoseToY(250), width: chartW, height: glucoseToY(180) - glucoseToY(250))) + + // Target lines + ctx.setStrokeColor(colorLow.withAlphaComponent(0.6).cgColor) + ctx.setLineWidth(0.8) + ctx.setLineDash(phase: 0, lengths: [4, 3]) + ctx.move(to: CGPoint(x: chartX, y: glucoseToY(70))) + ctx.addLine(to: CGPoint(x: chartX + chartW, y: glucoseToY(70))) + ctx.strokePath() - ctx.setLineDash(phase: 0, lengths: [3, 2]) - for (val, clr) in [(70.0, C_LOW), (180.0, C_HIGH)] { - ctx.setStrokeColor(clr.withAlphaComponent(0.5).cgColor); ctx.setLineWidth(0.6) - ctx.move(to: CGPoint(x: cx, y: gy(val))); ctx.addLine(to: CGPoint(x: cx + cw, y: gy(val))); ctx.strokePath() - } + ctx.setStrokeColor(colorHigh.withAlphaComponent(0.6).cgColor) + ctx.move(to: CGPoint(x: chartX, y: glucoseToY(180))) + ctx.addLine(to: CGPoint(x: chartX + chartW, y: glucoseToY(180))) + ctx.strokePath() ctx.setLineDash(phase: 0, lengths: []) - var band = CGMutablePath() + // 5–95 band + let p5Path = CGMutablePath() + let p95Path = CGMutablePath() + var bandPath = CGMutablePath() + for (i, pt) in agpData.enumerated() { - let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p95)); i == 0 ? band.move(to: p) : band.addLine(to: p) + let x = timeToX(pt.timeOfDay) + let y5 = glucoseToY(pt.p5) + let y95 = glucoseToY(pt.p95) + if i == 0 { + p5Path.move(to: CGPoint(x: x, y: y5)) + p95Path.move(to: CGPoint(x: x, y: y95)) + bandPath.move(to: CGPoint(x: x, y: y95)) + } else { + p5Path.addLine(to: CGPoint(x: x, y: y5)) + p95Path.addLine(to: CGPoint(x: x, y: y95)) + bandPath.addLine(to: CGPoint(x: x, y: y95)) + } } + // Close band with p5 reversed for pt in agpData.reversed() { - band.addLine(to: CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p5))) + bandPath.addLine(to: CGPoint(x: timeToX(pt.timeOfDay), y: glucoseToY(pt.p5))) } - band.closeSubpath() - ctx.setFillColor(accent(cfg).withAlphaComponent(0.10).cgColor); ctx.addPath(band); ctx.fillPath() + bandPath.closeSubpath() + ctx.setFillColor(colorBlue.withAlphaComponent(0.12).cgColor) + ctx.addPath(bandPath) + ctx.fillPath() - var iqr = CGMutablePath() + // 25–75 band + var iqrPath = CGMutablePath() for (i, pt) in agpData.enumerated() { - let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p75)); i == 0 ? iqr.move(to: p) : iqr.addLine(to: p) + let x = timeToX(pt.timeOfDay) + if i == 0 { iqrPath.move(to: CGPoint(x: x, y: glucoseToY(pt.p75))) } + else { iqrPath.addLine(to: CGPoint(x: x, y: glucoseToY(pt.p75))) } } for pt in agpData.reversed() { - iqr.addLine(to: CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p25))) + iqrPath.addLine(to: CGPoint(x: timeToX(pt.timeOfDay), y: glucoseToY(pt.p25))) } - iqr.closeSubpath() - ctx.setFillColor(accent(cfg).withAlphaComponent(0.25).cgColor); ctx.addPath(iqr); ctx.fillPath() + iqrPath.closeSubpath() + ctx.setFillColor(colorBlue.withAlphaComponent(0.25).cgColor) + ctx.addPath(iqrPath) + ctx.fillPath() - ctx.setStrokeColor(accent(cfg).cgColor); ctx.setLineWidth(1.6) + // Median line + ctx.setStrokeColor(colorBlue.cgColor) + ctx.setLineWidth(1.8) var first = true for pt in agpData { - let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p50)); first ? ctx.move(to: p) : ctx.addLine(to: p); first = false + let pt2D = CGPoint(x: timeToX(pt.timeOfDay), y: glucoseToY(pt.p50)) + if first { ctx.move(to: pt2D); first = false } + else { ctx.addLine(to: pt2D) } } ctx.strokePath() - let axA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] - for bg in [70, 140, 180, 250] { - let ly = gy(Double(bg)); guard ly >= cy, ly <= cy + ch else { continue } - let lbl = cfg.isMMOL ? String(format: "%.1f", Double(bg) * 0.0555) : "\(bg)" - let lsz = (lbl as NSString).size(withAttributes: axA) - (lbl as NSString).draw(at: CGPoint(x: x + lPad - lsz.width - 3, y: ly - lsz.height / 2), withAttributes: axA) - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.25) - ctx.move(to: CGPoint(x: cx, y: ly)); ctx.addLine(to: CGPoint(x: cx + cw, y: ly)); ctx.strokePath() - } - - for h2 in stride(from: 0, through: 24, by: 3) { - let lx = tx(h2 * 60) - let lbl = String(format: "%02d:00", h2) - let lsz = (lbl as NSString).size(withAttributes: axA) - let dx = Swift.max(cx, Swift.min(cx + cw - lsz.width, lx - lsz.width / 2)) - (lbl as NSString).draw(at: CGPoint(x: dx, y: cy + ch + 3), withAttributes: axA) - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.25) - ctx.move(to: CGPoint(x: lx, y: cy)); ctx.addLine(to: CGPoint(x: lx, y: cy + ch)); ctx.strokePath() - } - - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) - ctx.stroke(CGRect(x: cx, y: cy, width: cw, height: ch)) - - let lgA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] - let lgItems: [(String, UIColor, Bool)] = [("Median", accent(cfg), false), - ("25–75th", accent(cfg).withAlphaComponent(0.4), true), - ("5–95th", accent(cfg).withAlphaComponent(0.18), true)] - var lgX = cx + cw - for item in lgItems.reversed() { - let lsz = (item.0 as NSString).size(withAttributes: lgA) - lgX -= lsz.width - (item.0 as NSString).draw(at: CGPoint(x: lgX, y: cy + ch + 11), withAttributes: lgA) - lgX -= 15 - item.2 ? { ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x: lgX, y: cy + ch + 12, width: 12, height: 8)) }() - : { ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x: lgX, y: cy + ch + 15, width: 12, height: 2)) }() - lgX -= 5 + // X axis labels + let axisAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 7), + .foregroundColor: UIColor.secondaryLabel, + ] + for h in stride(from: 0, through: 24, by: 3) { + let lbl = String(format: "%02d:00", h) + let lx = timeToX(h * 60) + let lsize = (lbl as NSString).size(withAttributes: axisAttrs) + (lbl as NSString).draw(at: CGPoint(x: lx - lsize.width / 2, y: chartY + chartH + 2), withAttributes: axisAttrs) + + // Vertical grid line + ctx.setStrokeColor(colorBorder.cgColor) + ctx.setLineWidth(0.3) + ctx.move(to: CGPoint(x: lx, y: chartY)) + ctx.addLine(to: CGPoint(x: lx, y: chartY + chartH)) + ctx.strokePath() } - } - @discardableResult - private static func drawSettingsGrid(_ items: [(String, String)], x: CGFloat, y: CGFloat, width: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat { - let count = CGFloat(items.count) - guard count > 0 else { return y } - let spacing: CGFloat = 4 - let cw = (width - (count - 1) * spacing) / count - var maxH: CGFloat = 0 - - for (i, item) in items.enumerated() { - let cx = x + CGFloat(i) * (cw + spacing) - let lines = item.1.components(separatedBy: "\n").filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } - let h = 12.0 + CGFloat(lines.count) * 9.5 + 4.0 - maxH = max(maxH, h) - - ctx.setFillColor(C_CLOUD.cgColor) - ctx.fill(CGRect(x: cx, y: y, width: cw, height: h)) - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(CGRect(x: cx, y: y, width: cw, height: h)) - - let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.8), .foregroundColor: C_SLATE] - let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg)] - - (item.0 as NSString).draw(at: CGPoint(x: cx + 4, y: y + 2.5), withAttributes: la) - var ly = y + 10.5 - for line in lines { - (line as NSString).draw(at: CGPoint(x: cx + 4, y: ly), withAttributes: va) - ly += 9.5 - } + // Y axis labels + for bg in [54, 70, 140, 180, 250, 350] { + let ly = glucoseToY(Double(bg)) + let lbl = "\(bg)" + let lsz = (lbl as NSString).size(withAttributes: axisAttrs) + (lbl as NSString).draw(at: CGPoint(x: chartX - lsz.width - 3, y: ly - lsz.height / 2), withAttributes: axisAttrs) } - return y + maxH + 4 - } - - @discardableResult - private static func drawNotesSection(_ notes: String, x: CGFloat, y: CGFloat, width: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat { - let headerY = sectionHdr("NOTES & OBSERVATIONS", y: y, m: x, w: width + x * 2, cfg: cfg, ctx: ctx) - let font = UIFont.systemFont(ofSize: 8) - let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: C_INK] - - let textRect = CGRect(x: x + 6, y: headerY + 4, width: width - 12, height: 1000) - let size = (notes as NSString).boundingRect(with: textRect.size, options: .usesLineFragmentOrigin, attributes: attributes, context: nil).size - let drawRect = CGRect(x: x + 6, y: headerY + 4, width: width - 12, height: size.height) - (notes as NSString).draw(in: drawRect, withAttributes: attributes) + // Border + ctx.setStrokeColor(colorBorder.cgColor) + ctx.setLineWidth(0.5) + ctx.stroke(CGRect(x: chartX, y: chartY, width: chartW, height: chartH)) - return headerY + 4 + size.height + 4 - } - - // MARK: - Format helpers - - private static func formatBasalRateForDisplay(_ input: String) -> String { - let lines = input.components(separatedBy: .newlines) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - - // Helper to extract a double from a string that might contain units or other text - func extractDouble(_ s: String) -> Double? { - let cleaned = s.replacingOccurrences(of: ",", with: ".") - .components(separatedBy: CharacterSet(charactersIn: "0123456789.").inverted) - .joined() - return Double(cleaned) - } - - if input.contains("=") || (input.contains(":") && lines.count > 1) { - var formatted: [String] = [] - for line in lines { - let sep = line.contains("=") ? "=" : ":" - let parts = line.components(separatedBy: sep).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - if parts.count >= 2, let last = parts.last, let rate = extractDouble(last) { - let timeKey = parts.dropLast().joined(separator: sep) - formatted.append("\(timeKey) = \(String(format: "%.2f", rate))") - } else { - formatted.append(line) - } + // Legend + let lgY = chartY + chartH + 12 + let legendItems: [(String, UIColor, Bool)] = [ + ("Median", colorBlue, false), + ("25–75th %ile", colorBlue.withAlphaComponent(0.4), true), + ("5–95th %ile", colorBlue.withAlphaComponent(0.18), true), + ] + var lgX = chartX + for item in legendItems { + ctx.setFillColor(item.1.cgColor) + ctx.fill(CGRect(x: lgX, y: lgY, width: item.2 ? 14 : 20, height: item.2 ? 8 : 2.5)) + if !item.2 { + // line for median + ctx.setFillColor(item.1.cgColor) + ctx.fill(CGRect(x: lgX, y: lgY + 2, width: 20, height: 2)) } - return formatted.isEmpty ? input : formatted.joined(separator: "\n") - } - - if let value = extractDouble(input) { - return String(format: "%.2f U/hr", value) - } - return input - } - - // MARK: - Basal Profile Helpers - - static func calculateDailyProgrammedBasal(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 = (i < sortedProfile.count - 1) ? Double(sortedProfile[i + 1].timeAsSeconds) : Double(secondsInDay) - let durationHours = (nextTime - currentTime) / 3600.0 - totalBasal += current.value * durationHours + let lgAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 7.5), + .foregroundColor: UIColor.secondaryLabel, + ] + let lsz = (item.0 as NSString).size(withAttributes: lgAttrs) + (item.0 as NSString).draw(at: CGPoint(x: lgX + (item.2 ? 18 : 24), y: lgY), withAttributes: lgAttrs) + lgX += lsz.width + (item.2 ? 18 : 24) + 16 } - return totalBasal + return lgY + 20 } - // MARK: - Day row - - private static func drawDayRow(ctx: CGContext, x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat, - day: String, dayData: DayData, cfg: EndoReportConfig, - basalProfile: [MainViewController.basalProfileStruct]) - { - ctx.setFillColor(C_WHITE.cgColor); ctx.fill(CGRect(x: x, y: y, width: w, height: h)) - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) - ctx.stroke(CGRect(x: x, y: y, width: w, height: h)) - - ctx.setFillColor(cfg.accentColor.cgColor) - ctx.fill(CGRect(x: x, y: y, width: 3, height: h)) - - let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" - let df2 = DateFormatter(); df2.dateFormat = "EEEE, MMM d, yyyy" - let date = df.date(from: day) ?? Date() - let dlA: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 9), .foregroundColor: C_INK] - df2.string(from: date).draw(at: CGPoint(x: x + 10, y: y + 5), withAttributes: dlA) - - // Statistics Container on the Right - let statsW: CGFloat = 115 - let statsX = x + w - statsW - let boxRect = CGRect(x: statsX, y: y + 1, width: statsW - 1, height: h - 2) - ctx.setFillColor(C_CLOUD.cgColor) - ctx.fill(boxRect) - - ctx.setStrokeColor(C_BORDER.cgColor) - ctx.setLineWidth(0.4) - ctx.move(to: CGPoint(x: statsX, y: y + 1)) - ctx.addLine(to: CGPoint(x: statsX, y: y + h - 1)) - ctx.strokePath() - - let vals = dayData.bg.map { Double($0.sgv) } - if !vals.isEmpty { - let n = Double(vals.count) - let avg = vals.reduce(0,+) / n - let tir = Double(vals.filter { $0 >= 70 && $0 <= 180 }.count) / n * 100 - let totalInsulin = dayData.bolus.map { $0.value }.reduce(0, +) + dayData.smb.map { $0.value }.reduce(0, +) - let dailyProgrammedBasal = calculateDailyProgrammedBasal(basalProfile: basalProfile) - - let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: C_SLATE] - let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: C_INK] - let tirC: UIColor = tir >= 70 ? C_IN : tir >= 50 ? C_HIGH : C_VLOW - let tirA: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: tirC] - - let padding: CGFloat = 8 - let col2X = statsX + statsW / 2 - - // Row 1: Avg & TIR - "Avg BG".draw(at: CGPoint(x: statsX + padding, y: y + 8), withAttributes: la) - cfg.fmtBG(avg).draw(at: CGPoint(x: statsX + padding, y: y + 15), withAttributes: va) - - "TIR".draw(at: CGPoint(x: col2X, y: y + 8), withAttributes: la) - String(format: "%.0f%%", tir).draw(at: CGPoint(x: col2X, y: y + 15), withAttributes: tirA) - - // Divider - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.3) - ctx.move(to: CGPoint(x: statsX + 5, y: y + 30)) - ctx.addLine(to: CGPoint(x: x + w - 5, y: y + 30)) - ctx.strokePath() - - // Row 2: Bolus & Basal - "Bolus Total".draw(at: CGPoint(x: statsX + padding, y: y + 36), withAttributes: la) - String(format: "%.1f U", totalInsulin).draw(at: CGPoint(x: statsX + padding, y: y + 43), withAttributes: va) - - "Basal Sched".draw(at: CGPoint(x: col2X, y: y + 36), withAttributes: la) - String(format: "%.1f U", dailyProgrammedBasal).draw(at: CGPoint(x: col2X, y: y + 43), withAttributes: va) + // MARK: - Daily summary table - // Divider - ctx.move(to: CGPoint(x: statsX + 5, y: y + 58)) - ctx.addLine(to: CGPoint(x: x + w - 5, y: y + 58)) - ctx.strokePath() - - // Row 3: Coverage - "Data Coverage".draw(at: CGPoint(x: statsX + padding, y: y + 64), withAttributes: la) - let coverage = String(format: "%.0f%%", Double(vals.count) / 2.88) - "\(vals.count) pts (\(coverage))".draw(at: CGPoint(x: statsX + padding, y: y + 71), withAttributes: va) - } - - let chartX = x + 10; let chartW = w - 140 - let chartY = y + 26; let chartH = h - 32 - - guard !dayData.bg.isEmpty else { return } - - ctx.saveGState() - ctx.clip(to: CGRect(x: chartX, y: chartY, width: chartW, height: chartH)) - - let bgMin: CGFloat = 40; let bgMax: CGFloat = 320; let bgRng = bgMax - bgMin - func gy(_ bg: Double) -> CGFloat { chartY + chartH - (CGFloat(bg) - bgMin) / bgRng * chartH } - func tx(_ ts: Double) -> CGFloat { - let cal = dateTimeUtils.displayCalendar() - let d = Date(timeIntervalSince1970: ts) - let c = cal.dateComponents([.hour, .minute], from: d) - let min = Double((c.hour ?? 0) * 60 + (c.minute ?? 0)) - return chartX + CGFloat(min / (24 * 60)) * chartW - } - - ctx.setFillColor(C_IN.withAlphaComponent(0.06).cgColor) - ctx.fill(CGRect(x: chartX, y: gy(180), width: chartW, height: gy(70) - gy(180))) - - ctx.setLineDash(phase: 0, lengths: [2, 2]); ctx.setLineWidth(0.4) - ctx.setStrokeColor(C_LOW.withAlphaComponent(0.4).cgColor) - ctx.move(to: CGPoint(x: chartX, y: gy(70))); ctx.addLine(to: CGPoint(x: chartX + chartW, y: gy(70))); ctx.strokePath() - ctx.setStrokeColor(C_HIGH.withAlphaComponent(0.4).cgColor) - ctx.move(to: CGPoint(x: chartX, y: gy(180))); ctx.addLine(to: CGPoint(x: chartX + chartW, y: gy(180))); ctx.strokePath() - ctx.setLineDash(phase: 0, lengths: []) + @discardableResult + private static func drawDailyTable(bgData: [ShareGlucoseData], y: CGFloat, in pageRect: CGRect, ctx: CGContext) -> CGFloat { + // Group by day + let calendar = dateTimeUtils.displayCalendar() + var byDay: [String: [Double]] = [:] + let df = DateFormatter() + df.dateFormat = "yyyy-MM-dd" - ctx.setStrokeColor(C_BORDER.withAlphaComponent(0.5).cgColor); ctx.setLineWidth(0.25) - for h2 in stride(from: 3, through: 21, by: 3) { - let hx = chartX + CGFloat(h2) / 24 * chartW - ctx.move(to: CGPoint(x: hx, y: chartY)); ctx.addLine(to: CGPoint(x: hx, y: chartY + chartH)); ctx.strokePath() + for r in bgData { + let d = df.string(from: Date(timeIntervalSince1970: r.date)) + byDay[d, default: []].append(Double(r.sgv)) + } + + let cols: [CGFloat] = [90, 60, 50, 50, 55, 60, 60] + let headers = ["Date", "Avg", "SD", "Min", "Max", "TIR %", "Readings"] + let totalW = cols.reduce(0, +) + let rowH: CGFloat = 14 + var curY = y + var curX = margin + + // Header + ctx.setFillColor(colorDark.cgColor) + ctx.fill(CGRect(x: margin, y: curY, width: totalW, height: rowH)) + for (i, h) in headers.enumerated() { + let attrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 7.5), + .foregroundColor: UIColor.white, + ] + (h as NSString).draw(at: CGPoint(x: curX + 3, y: curY + 3), withAttributes: attrs) + curX += cols[i] } + curY += rowH - if !dayData.basal.isEmpty { - let bH = chartH * 0.25; let bY = chartY + chartH - bH - let sorted = dayData.basal.sorted { $0.date < $1.date } - let maxR = Swift.max(sorted.map { $0.basalRate }.max() ?? 1, 0.01) + let dayAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 7.5), + .foregroundColor: colorDark, + ] + let df2 = DateFormatter() + df2.dateFormat = "EEE MMM d" + + for (ri, day) in byDay.keys.sorted().enumerated() { + let vals = byDay[day]! + let n = Double(vals.count) + let mean = vals.reduce(0, +) / n + let sd = sqrt(vals.map { ($0 - mean) * ($0 - mean) }.reduce(0, +) / n) + let tir = vals.filter { $0 >= 70 && $0 <= 180 }.count + let tirPct = Double(tir) / n * 100 + + let date = df.date(from: day) ?? Date() + let cells = [ + df2.string(from: date), + String(format: "%.0f", mean), + String(format: "%.0f", sd), + String(format: "%.0f", vals.min() ?? 0), + String(format: "%.0f", vals.max() ?? 0), + String(format: "%.0f%%", tirPct), + "\(vals.count)", + ] - var path = CGMutablePath(); var first = true - for pt in sorted { - let px = tx(pt.date); let py = bY + bH - CGFloat(pt.basalRate / maxR) * bH - first ? path.move(to: CGPoint(x: px, y: py)) : path.addLine(to: CGPoint(x: px, y: py)); first = false - } - if let last = sorted.last { - path.addLine(to: CGPoint(x: tx(last.date), y: bY + bH)) - path.addLine(to: CGPoint(x: chartX, y: bY + bH)); path.closeSubpath() - ctx.setFillColor(C_BASAL.withAlphaComponent(0.15).cgColor); ctx.addPath(path); ctx.fillPath() - } + ctx.setFillColor((ri % 2 == 0 ? UIColor.white : colorLightGray).cgColor) + ctx.fill(CGRect(x: margin, y: curY, width: totalW, height: rowH)) - var lp = CGMutablePath(); first = true - for (index, pt) in sorted.enumerated() { - let px = tx(pt.date); let py = bY + bH - CGFloat(pt.basalRate / maxR) * bH - first ? lp.move(to: CGPoint(x: px, y: py)) : lp.addLine(to: CGPoint(x: px, y: py)); first = false - - if pt.basalRate > 0.01 { - let nextX = index < sorted.count - 1 ? tx(sorted[index + 1].date) : (chartX + chartW) - if (nextX - px) > 14 { - let rateStr = String(format: "%.2f", pt.basalRate) - let rA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 4.2), .foregroundColor: C_BASAL] - rateStr.draw(at: CGPoint(x: px + 1, y: py - 7), withAttributes: rA) - } - } + curX = margin + for (ci, cell) in cells.enumerated() { + (cell as NSString).draw(at: CGPoint(x: curX + 3, y: curY + 3), withAttributes: dayAttrs) + curX += cols[ci] } - ctx.setStrokeColor(C_BASAL.cgColor); ctx.setLineWidth(0.9); ctx.addPath(lp); ctx.strokePath() + ctx.setStrokeColor(colorBorder.cgColor) + ctx.setLineWidth(0.3) + ctx.stroke(CGRect(x: margin, y: curY, width: totalW, height: rowH)) + curY += rowH } - // Draw Carbs as small green diamonds/circles at the top of the chart - for carb in dayData.carbs { - let cx = tx(carb.date) - let cy = chartY + 4 - ctx.setFillColor(C_CARB.cgColor) - ctx.fillEllipse(in: CGRect(x: cx - 2.5, y: cy - 2.5, width: 5, height: 5)) - } + return curY + 14 + } - for smb in dayData.smb { - let bx = tx(smb.date); let bh2 = max(CGFloat(Swift.min(smb.value / 15, 1)) * (chartH * 0.35), 2.5) - ctx.setFillColor(C_SMB.cgColor) - ctx.fill(CGRect(x: bx - 2, y: chartY + chartH - bh2, width: 4, height: bh2)) - } + // MARK: - Insulin & carb summary - for bolus in dayData.bolus { - let bx = tx(bolus.date); let bh2 = max(CGFloat(Swift.min(bolus.value / 15, 1)) * (chartH * 0.4), 3.0) - ctx.setFillColor(C_BOLUS.cgColor) - ctx.fill(CGRect(x: bx - 2.5, y: chartY + chartH - bh2, width: 5, height: bh2)) - } - - let sortedBG = dayData.bg.sorted(by: { $0.date < $1.date }) - for r in sortedBG { - let rx = tx(r.date); let ry = gy(Double(r.sgv)) - ctx.setFillColor(bgColor(Double(r.sgv)).cgColor) - ctx.fillEllipse(in: CGRect(x: rx - 1.6, y: ry - 1.6, width: 3.2, height: 3.2)) - } + @discardableResult + private static func drawInsulinCarbSummary( + boluses: [MainViewController.bolusGraphStruct], + smbs: [MainViewController.bolusGraphStruct], + carbs: [MainViewController.carbGraphStruct], + stats: SimpleStats, + y: CGFloat, + in pageRect: CGRect, + ctx: CGContext + ) -> CGFloat { + let days = max(stats.sensorPct / 100 * 14, 1) + let totalBolus = boluses.map { $0.value }.reduce(0, +) + + smbs.map { $0.value }.reduce(0, +) + let totalCarbs = carbs.map { $0.value }.reduce(0, +) + + let cards: [(String, String, String)] = [ + ("Avg Daily Bolus", String(format: "%.1f U", totalBolus / days), "Total \(String(format: "%.1f", totalBolus)) U"), + ("Bolus Count", "\(boluses.count + smbs.count)", "Over report period"), + ("Avg Daily Carbs", String(format: "%.0f g", totalCarbs / days), "Total \(String(format: "%.0f", totalCarbs)) g"), + ("Carb Entries", "\(carbs.count)", "Logged entries"), + ] - ctx.restoreGState() + let cardW = (pageRect.width - margin * 2) / CGFloat(cards.count) + let cardH: CGFloat = 48 + + for (i, card) in cards.enumerated() { + let cardX = margin + CGFloat(i) * cardW + let rect = CGRect(x: cardX, y: y, width: cardW - 4, height: cardH) + ctx.setFillColor(colorLightGray.cgColor) + ctx.fill(rect) + ctx.setStrokeColor(colorBorder.cgColor) + ctx.setLineWidth(0.5) + ctx.stroke(rect) + + let valAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 14), + .foregroundColor: colorDark, + ] + let valSz = (card.1 as NSString).size(withAttributes: valAttrs) + (card.1 as NSString).draw(at: CGPoint(x: cardX + (cardW - 4 - valSz.width) / 2, y: y + 6), withAttributes: valAttrs) - let axA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: C_SLATE] - for h2 in [0, 6, 12, 18, 24] { - let hx = chartX + CGFloat(h2) / 24 * chartW - let lbl = String(format: "%02d", h2) - let sz = (lbl as NSString).size(withAttributes: axA) - (lbl as NSString).draw(at: CGPoint(x: hx - sz.width / 2, y: chartY + chartH + 2), withAttributes: axA) - } + let lblAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 7.5), + .foregroundColor: UIColor.secondaryLabel, + ] + let lsz = (card.0 as NSString).size(withAttributes: lblAttrs) + (card.0 as NSString).draw(at: CGPoint(x: cardX + (cardW - 4 - lsz.width) / 2, y: y + 24), withAttributes: lblAttrs) - // Legend moved to top area next to date - let lgA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: C_SLATE] - var lgX = x + 120 - for (lbl, clr) in [("● BG", C_IN), ("● Carbs", C_CARB), ("▮ Bolus", C_BOLUS), ("▮ SMB", C_SMB), ("— Basal", C_BASAL)] { - let a: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: clr] - (lbl as NSString).draw(at: CGPoint(x: lgX, y: y + 7), withAttributes: a) - lgX += (lbl as NSString).size(withAttributes: lgA).width + 5 - if lgX > statsX - 4 { break } + let ssz = (card.2 as NSString).size(withAttributes: lblAttrs) + (card.2 as NSString).draw(at: CGPoint(x: cardX + (cardW - 4 - ssz.width) / 2, y: y + 34), withAttributes: lblAttrs) } - } - // MARK: - Footer - - private static func drawFooter(ctx: CGContext, r: CGRect, cfg _: EndoReportConfig, - stats: ReportStats, page: Int) - { - let fy = r.height - 28 - ctx.setFillColor(C_INK.cgColor); ctx.fill(CGRect(x: 0, y: fy, width: r.width, height: 28)) - let a: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_WHITE.withAlphaComponent(0.5)] - "Loop Follow — for informational purposes only. Not a substitute for professional medical advice." - .draw(at: CGPoint(x: 30, y: fy + 4), withAttributes: a) - let df = DateFormatter(); df.dateFormat = "MMM d, yyyy" - let meta = "Generated: \(df.string(from: Date())) • \(Int(stats.days.rounded())) Days • \(stats.readingCount) readings • Page \(page)" - let msz = (meta as NSString).size(withAttributes: a) - (meta as NSString).draw(at: CGPoint(x: r.width - 30 - msz.width, y: fy + 4), withAttributes: a) + return y + cardH + 14 } } From 693a929b7e180b019f15538ce8ab92befd07f484 Mon Sep 17 00:00:00 2001 From: greyghost99 <164137251+greyghost99@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:26:53 -0700 Subject: [PATCH 5/5] Fix Endo report config redeclaration and improve report layout spacing --- LoopFollow/Stats/EndoReportConfig.swift | 42 + LoopFollow/Stats/EndoReportGenerator.swift | 1570 ++++++++++++-------- LoopFollow/Stats/EndoReportView.swift | 186 +-- 3 files changed, 1010 insertions(+), 788 deletions(-) create mode 100644 LoopFollow/Stats/EndoReportConfig.swift diff --git a/LoopFollow/Stats/EndoReportConfig.swift b/LoopFollow/Stats/EndoReportConfig.swift new file mode 100644 index 000000000..f43bae859 --- /dev/null +++ b/LoopFollow/Stats/EndoReportConfig.swift @@ -0,0 +1,42 @@ +// LoopFollow +// EndoReportConfig.swift + +import UIKit + +struct EndoReportConfig { + let patientName: String + let dateOfBirth: String + let diagnosisDate: String + let providerName: String + let insulinType: String + let aidSystem: String + let pumpDevice: String + let cgmDevice: String + let carbRatio: String + let isf: String + let basalRate: String + let targetGlucose: String + let units: String + let accentColorHex: String + let notes: String + let includeGlucoseSummary: Bool + let includeInsulin: Bool + let includeNutrition: Bool + let includeTherapySettings: Bool + let includeDevices: Bool + let includeAGP: Bool + let includeDailyBreakdown: Bool + let includeFatProtein: Bool + let startDate: Date + let endDate: Date + + var accentColor: UIColor { + UIColor(hex: accentColorHex) ?? UIColor(red: 0.137, green: 0.624, blue: 0.675, alpha: 1) + } + + var isMMOL: Bool { units == "mmol/L" } + func convert(_ mgdl: Double) -> Double { isMMOL ? mgdl * 0.0555 : mgdl } + func fmtBG(_ mgdl: Double) -> String { + isMMOL ? String(format: "%.1f", mgdl * 0.0555) : String(format: "%.0f", mgdl) + } +} diff --git a/LoopFollow/Stats/EndoReportGenerator.swift b/LoopFollow/Stats/EndoReportGenerator.swift index 6ad1566b6..91274cfaf 100644 --- a/LoopFollow/Stats/EndoReportGenerator.swift +++ b/LoopFollow/Stats/EndoReportGenerator.swift @@ -1,771 +1,1041 @@ // LoopFollow // EndoReportGenerator.swift -// -// Generates a PDF endo report by: -// 1. Reusing the existing AGPCalculator, TIRCalculator, and StatsDataService -// 2. Snapshotting the existing AGPGraphView and TIRGraphView into images -// 3. Assembling a multi-page PDF with PDFKit import PDFKit -import SwiftUI import UIKit -enum EndoReportGenerator { - - // MARK: - Public entry point - - /// Generates a PDF and returns the file URL, or throws on failure. - static func generate( - patientName: String, - dateOfBirth: String, - providerName: String, - startDate: Date, - endDate: Date, - dataService: StatsDataService - ) throws -> URL { +// MARK: - Generator - let bgData = dataService.getBGData() - guard !bgData.isEmpty else { - throw ReportError.noData - } - - let agpData = AGPCalculator.calculate(bgData: bgData) - let tirData = TIRCalculator.calculate(bgData: bgData) - let stats = SimpleStats(bgData: bgData, dataService: dataService) +enum EndoReportGenerator { + enum ReportError: LocalizedError { + case noData + var errorDescription: String? { "No CGM data available for the selected date range." } + } - let pageRect = CGRect(origin: .zero, size: CGSize(width: 612, height: 792)) // US Letter + static func generate(config: EndoReportConfig, dataService: StatsDataService) throws -> URL { + let bgData = dataService.getBGData() + guard !bgData.isEmpty else { throw ReportError.noData } + + // Use the existing ViewModels for calculations + let agpVM = AGPViewModel(dataService: dataService) + agpVM.calculateAGP() + + let stats = ReportStats(bgData: bgData, dataService: dataService) + let patterns = TimePatterns(bgData: bgData) + let boluses = dataService.getBolusData() + let smbs = dataService.getSMBData() + let carbs = dataService.getCarbData() + let basals = dataService.getBasalData() + let basalProfile = dataService.getBasalProfile() // Get basal profile here + let simpleVM = SimpleStatsViewModel(dataService: dataService) + simpleVM.calculateStats() + + let pageRect = CGRect(origin: .zero, size: CGSize(width: 612, height: 792)) let renderer = UIGraphicsPDFRenderer(bounds: pageRect) - let url = FileManager.default.temporaryDirectory .appendingPathComponent("EndoReport_\(Int(Date().timeIntervalSince1970)).pdf") + let dailyData = groupByDay(bgData: bgData, boluses: boluses, smbs: smbs, basals: basals, carbs: carbs) + .sorted { $0.key > $1.key } + let data = renderer.pdfData { ctx in - // ── Page 1: Summary + AGP ────────────────────────────────────── - ctx.beginPage() - var cursor = drawHeader( - ctx: ctx.cgContext, - pageRect: pageRect, - patientName: patientName, - dateOfBirth: dateOfBirth, - providerName: providerName, - startDate: startDate, - endDate: endDate - ) - - cursor = drawSectionTitle("Key Metrics", y: cursor, in: pageRect, ctx: ctx.cgContext) - cursor = drawKeyMetrics(stats: stats, y: cursor, in: pageRect, ctx: ctx.cgContext) - - cursor = drawSectionTitle("Time in Range", y: cursor, in: pageRect, ctx: ctx.cgContext) - cursor = drawTIRBar(tirData: tirData, y: cursor, in: pageRect, ctx: ctx.cgContext) - cursor = drawTIRTable(tirData: tirData, y: cursor, in: pageRect, ctx: ctx.cgContext) - - cursor = drawSectionTitle("Ambulatory Glucose Profile (AGP)", y: cursor, in: pageRect, ctx: ctx.cgContext) - cursor = drawAGPChart(agpData: agpData, y: cursor, in: pageRect, ctx: ctx.cgContext) - drawFooter(ctx: ctx.cgContext, pageRect: pageRect, page: 1) - - // ── Page 2: Daily stats + Insulin/Carbs ─────────────────────── + // Page 1 — Summary ctx.beginPage() - var cursor2 = drawPageContinuationHeader(ctx: ctx.cgContext, pageRect: pageRect, - patientName: patientName, - startDate: startDate, endDate: endDate) - - cursor2 = drawSectionTitle("Daily Glucose Summary", y: cursor2, in: pageRect, ctx: ctx.cgContext) - cursor2 = drawDailyTable(bgData: bgData, y: cursor2, in: pageRect, ctx: ctx.cgContext) - - // Insulin & carbs if available - let boluses = dataService.getBolusData() - let smbs = dataService.getSMBData() - let carbs = dataService.getCarbData() - if !boluses.isEmpty || !smbs.isEmpty || !carbs.isEmpty { - cursor2 = drawSectionTitle("Insulin & Carbohydrate Summary", - y: cursor2, in: pageRect, ctx: ctx.cgContext) - cursor2 = drawInsulinCarbSummary(boluses: boluses, smbs: smbs, carbs: carbs, - stats: stats, y: cursor2, - in: pageRect, ctx: ctx.cgContext) + drawSummaryPage(ctx: ctx.cgContext, r: pageRect, cfg: config, + bgData: bgData, agpData: agpVM.agpData, + stats: stats, patterns: patterns, + boluses: boluses, smbs: smbs, carbs: carbs, + simpleVM: simpleVM) + + // Pages 2+ — Daily breakdowns + if config.includeDailyBreakdown && !dailyData.isEmpty { + let rowH: CGFloat = 88 + let rowGap: CGFloat = 6 + let topY: CGFloat = 52 + let botY: CGFloat = 762 + let usable = botY - topY + let perPage = Int((usable + rowGap) / (rowH + rowGap)) + let pages = Int(ceil(Double(dailyData.count) / Double(perPage))) + + for p in 0 ..< pages { + ctx.beginPage() + let pageNum = p + 2 + let headerY = drawDailyPageHeader(ctx: ctx.cgContext, r: pageRect, + cfg: config, page: pageNum, + totalPages: pages + 1) + let slice = Array(dailyData[p * perPage ..< min((p + 1) * perPage, dailyData.count)]) + var y = headerY + 8 + for (day, dayData) in slice { + drawDayRow(ctx: ctx.cgContext, x: 28, y: y, + w: pageRect.width - 56, h: rowH, + day: day, dayData: dayData, cfg: config, basalProfile: basalProfile) + y += rowH + rowGap + } + drawFooter(ctx: ctx.cgContext, r: pageRect, cfg: config, + stats: stats, page: pageNum) + } } - - drawFooter(ctx: ctx.cgContext, pageRect: pageRect, page: 2) } - try data.write(to: url) return url } - // MARK: - Errors + // MARK: - Data models - enum ReportError: LocalizedError { - case noData - var errorDescription: String? { - switch self { - case .noData: return "No CGM data available for the selected date range." - } + struct ReportStats { + let avg, stdDev, cv, eA1C, minBG, maxBG, sensorPct, tir, tightTIR, days: Double + let veryLow, low, inRange, high, veryHigh: Double + let readingCount: Int + + init(bgData: [ShareGlucoseData], dataService: StatsDataService) { + let v = bgData.map { Double($0.sgv) }; let n = Double(v.count) + let m = v.reduce(0,+) / n + let variance = v.map { ($0 - m) * ($0 - m) }.reduce(0,+) / n + + avg = m; stdDev = sqrt(variance); cv = stdDev / m * 100; eA1C = (m + 46.7) / 28.7 + minBG = v.min() ?? 0; maxBG = v.max() ?? 0; readingCount = v.count + days = Swift.max(dataService.endDate.timeIntervalSince1970 - dataService.startDate.timeIntervalSince1970, 86400) / 86400 + sensorPct = Swift.min(Double(v.count) / (days * 288) * 100, 100) + + // Calculate TIR Buckets + let vLowCount = Double(v.filter { $0 < 54 }.count) + let lowCount = Double(v.filter { $0 >= 54 && $0 < 70 }.count) + let inRangeCount = Double(v.filter { $0 >= 70 && $0 <= 180 }.count) + let highCount = Double(v.filter { $0 > 180 && $0 <= 250 }.count) + let vHighCount = Double(v.filter { $0 > 250 }.count) + + veryLow = (vLowCount / n) * 100 + low = (lowCount / n) * 100 + inRange = (inRangeCount / n) * 100 + high = (highCount / n) * 100 + veryHigh = (vHighCount / n) * 100 + tir = inRange + tightTIR = Double(v.filter { $0 >= 70 && $0 <= 140 }.count) / n * 100 } } - // MARK: - Computed stats helper + struct TimePatterns { + struct Period { let label: String; let avg: Double; let count: Int } + let night, earlyAM, morning, afternoon, evening, late: Period + init(bgData: [ShareGlucoseData]) { + func p(_ l: String, _ s: Int, _ e: Int) -> Period { + let cal = dateTimeUtils.displayCalendar() + let r = bgData.filter { let h = cal.component(.hour, from: Date(timeIntervalSince1970: $0.date)); return h >= s && h < e } + return Period(label: l, avg: r.isEmpty ? 0 : r.map { Double($0.sgv) }.reduce(0,+) / Double(r.count), count: r.count) + } + night = p("Night", 0, 3); earlyAM = p("Early AM", 3, 6); morning = p("Morning", 6, 12) + afternoon = p("Afternoon", 12, 17); evening = p("Evening", 17, 21); late = p("Late", 21, 24) + } + } - struct SimpleStats { - let avg: Double - let stdDev: Double - let cv: Double - let eA1C: Double - let min: Double - let max: Double - let sensorPct: Double - let readingCount: Int + struct DayData { + let bg: [ShareGlucoseData] + let bolus: [MainViewController.bolusGraphStruct] + let smb: [MainViewController.bolusGraphStruct] + let basal: [MainViewController.basalGraphStruct] + let carbs: [MainViewController.carbGraphStruct] + } - init(bgData: [ShareGlucoseData], dataService: StatsDataService) { - let vals = bgData.map { Double($0.sgv) } - let n = Double(vals.count) - let mean = vals.reduce(0, +) / n - let variance = vals.map { ($0 - mean) * ($0 - mean) }.reduce(0, +) / n - avg = mean - stdDev = sqrt(variance) - cv = stdDev / mean * 100 - eA1C = (mean + 46.7) / 28.7 - min = vals.min() ?? 0 - max = vals.max() ?? 0 - readingCount = vals.count - - let days = max(dataService.endDate.timeIntervalSince1970 - dataService.startDate.timeIntervalSince1970, 86400) / 86400 - let expected = days * 288 - sensorPct = Swift.min(Double(vals.count) / expected * 100, 100) + private static func groupByDay( + bgData: [ShareGlucoseData], + boluses: [MainViewController.bolusGraphStruct], + smbs: [MainViewController.bolusGraphStruct], + basals: [MainViewController.basalGraphStruct], + carbs: [MainViewController.carbGraphStruct] + ) -> [String: DayData] { + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + var bg: [String: [ShareGlucoseData]] = [:] + var bo: [String: [MainViewController.bolusGraphStruct]] = [:] + var sm: [String: [MainViewController.bolusGraphStruct]] = [:] + var ba: [String: [MainViewController.basalGraphStruct]] = [:] + var ca: [String: [MainViewController.carbGraphStruct]] = [:] + for r in bgData { + let k = df.string(from: Date(timeIntervalSince1970: r.date)); bg[k, default: []].append(r) + } + for r in boluses { + let k = df.string(from: Date(timeIntervalSince1970: r.date)); bo[k, default: []].append(r) } + for r in smbs { + let k = df.string(from: Date(timeIntervalSince1970: r.date)); sm[k, default: []].append(r) + } + for r in basals { + let k = df.string(from: Date(timeIntervalSince1970: r.date)); ba[k, default: []].append(r) + } + for r in carbs { + let k = df.string(from: Date(timeIntervalSince1970: r.date)); ca[k, default: []].append(r) + } + var result: [String: DayData] = [:] + for k in bg.keys { + result[k] = DayData(bg: bg[k]!, bolus: bo[k] ?? [], smb: sm[k] ?? [], basal: ba[k] ?? [], carbs: ca[k] ?? []) + } + return result } - // MARK: - Layout constants + // MARK: - Colors / fonts - private static let margin: CGFloat = 36 - private static let bodyFont = UIFont.systemFont(ofSize: 9) - private static let labelFont = UIFont.systemFont(ofSize: 8) - private static let boldFont = UIFont.boldSystemFont(ofSize: 9) - private static let titleFont = UIFont.boldSystemFont(ofSize: 11) - private static let sectionFont = UIFont.boldSystemFont(ofSize: 10) + private static func accent(_ cfg: EndoReportConfig) -> UIColor { cfg.accentColor } + private static func accentDark(_ cfg: EndoReportConfig) -> UIColor { + var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + cfg.accentColor.getHue(&h, saturation: &s, brightness: &b, alpha: &a) + return UIColor(hue: h, saturation: s, brightness: b * 0.72, alpha: a) + } - private static let colorVeryLow = UIColor(red: 0.957, green: 0.263, blue: 0.212, alpha: 1) - private static let colorLow = UIColor(red: 1.000, green: 0.596, blue: 0.000, alpha: 1) - private static let colorInRange = UIColor(red: 0.298, green: 0.686, blue: 0.314, alpha: 1) - private static let colorHigh = UIColor(red: 1.000, green: 0.757, blue: 0.027, alpha: 1) - private static let colorVeryHigh = UIColor(red: 1.000, green: 0.341, blue: 0.133, alpha: 1) - private static let colorBlue = UIColor(red: 0.102, green: 0.451, blue: 0.933, alpha: 1) - private static let colorDark = UIColor(red: 0.110, green: 0.169, blue: 0.227, alpha: 1) - private static let colorLightGray = UIColor(red: 0.957, green: 0.961, blue: 0.976, alpha: 1) - private static let colorBorder = UIColor(red: 0.867, green: 0.890, blue: 0.925, alpha: 1) + private static let C_INK = UIColor(red: 0.133, green: 0.157, blue: 0.192, alpha: 1) + private static let C_SLATE = UIColor(red: 0.400, green: 0.440, blue: 0.490, alpha: 1) + private static let C_CLOUD = UIColor(red: 0.960, green: 0.963, blue: 0.970, alpha: 1) + private static let C_BORDER = UIColor(red: 0.870, green: 0.885, blue: 0.905, alpha: 1) + private static let C_WHITE = UIColor.white + private static let C_VLOW = UIColor(red: 0.820, green: 0.180, blue: 0.180, alpha: 1) + private static let C_LOW = UIColor(red: 0.929, green: 0.490, blue: 0.188, alpha: 1) + private static let C_IN = UIColor(red: 0.200, green: 0.670, blue: 0.470, alpha: 1) + private static let C_HIGH = UIColor(red: 0.910, green: 0.740, blue: 0.220, alpha: 1) + private static let C_VHIGH = UIColor(red: 0.800, green: 0.340, blue: 0.340, alpha: 1) + private static let C_BOLUS = UIColor(red: 0.380, green: 0.220, blue: 0.780, alpha: 0.85) + private static let C_SMB = UIColor(red: 0.800, green: 0.200, blue: 0.600, alpha: 0.75) + private static let C_CARB = UIColor(red: 0.150, green: 0.600, blue: 0.150, alpha: 1.0) + private static let C_BASAL = UIColor(red: 0.102, green: 0.451, blue: 0.933, alpha: 0.65) + + private static func bgColor(_ bg: Double) -> UIColor { + switch bg { case ..<54: return C_VLOW; case ..<70: return C_LOW; case ...180: return C_IN; case ...250: return C_HIGH; default: return C_VHIGH } + } - // MARK: - Header / Footer + // MARK: - Page 1: Summary - @discardableResult - private static func drawHeader( - ctx: CGContext, - pageRect: CGRect, - patientName: String, - dateOfBirth: String, - providerName: String, - startDate: Date, - endDate: Date - ) -> CGFloat { - - let headerH: CGFloat = 52 - ctx.setFillColor(colorDark.cgColor) - ctx.fill(CGRect(x: 0, y: 0, width: pageRect.width, height: headerH)) - - let titleAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 14), - .foregroundColor: UIColor.white, - ] - "Continuous Glucose Monitor Report".draw(at: CGPoint(x: margin, y: 10), withAttributes: titleAttrs) - - let df = DateFormatter() - df.dateFormat = "MMM d, yyyy" - let rangeStr = "\(df.string(from: startDate)) – \(df.string(from: endDate))" - let subAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 9), - .foregroundColor: UIColor(white: 0.8, alpha: 1), - ] - let rangeSize = (rangeStr as NSString).size(withAttributes: subAttrs) - (rangeStr as NSString).draw( - at: CGPoint(x: pageRect.width - margin - rangeSize.width, y: 12), - withAttributes: subAttrs - ) - - // Patient info bar - let infoY: CGFloat = 28 - let infoAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 8.5), - .foregroundColor: UIColor(white: 0.75, alpha: 1), - ] - "Patient: \(patientName)".draw(at: CGPoint(x: margin, y: infoY), withAttributes: infoAttrs) - if !dateOfBirth.isEmpty { - "DOB: \(dateOfBirth)".draw(at: CGPoint(x: margin + 180, y: infoY), withAttributes: infoAttrs) + private static func drawSummaryPage( + ctx: CGContext, r: CGRect, cfg: EndoReportConfig, + bgData _: [ShareGlucoseData], agpData: [AGPDataPoint], + stats: ReportStats, patterns: TimePatterns, + boluses: [MainViewController.bolusGraphStruct], + smbs: [MainViewController.bolusGraphStruct], + carbs: [MainViewController.carbGraphStruct], + simpleVM: SimpleStatsViewModel + ) { + let m: CGFloat = 24 + var y = drawHero(ctx: ctx, r: r, cfg: cfg, stats: stats) + + if cfg.includeGlucoseSummary { + y = sectionHdr("GLUCOSE SUMMARY", y: y + 2, m: m, w: r.width, cfg: cfg, ctx: ctx) + + let gridW: CGFloat = r.width - m * 2 - 158 + let cw = gridW / 2 - 3; let ch: CGFloat = 36 + let cards: [(String, String, Bool)] = [ + ("TIME IN RANGE (>70%)", String(format: "%.0f%%", stats.tir), true), + ("GMI (TARGET <7%)", String(format: "%.1f%%", stats.eA1C), false), + ("AVERAGE", cfg.fmtBG(stats.avg) + " \(cfg.units)", false), + ("STD DEVIATION", cfg.fmtBG(stats.stdDev), false), + ("CV (TARGET <36%)", String(format: "%.0f%%", stats.cv), false), + ("READINGS", "\(stats.readingCount)", false), + ] + var gy = y + 1 + for (i, c) in cards.enumerated() { + statCard(c.0, val: c.1, x: m + CGFloat(i % 2) * (cw + 6), y: gy + CGFloat(i / 2) * (ch + 4), + w: cw, h: ch, accent: c.2, cfg: cfg, ctx: ctx) + } + drawTIRBar(stats: stats, x: m + gridW + 10, y: y + 1, + w: 148, h: ch * 3 + 7, cfg: cfg, ctx: ctx) + y = gy + CGFloat(3) * (ch + 4) + 1 + + y = timeStrip(patterns: patterns, cfg: cfg, y: y + 1, m: m, w: r.width, ctx: ctx) } - if !providerName.isEmpty { - let provStr = "Provider: \(providerName)" - let provSize = (provStr as NSString).size(withAttributes: infoAttrs) - (provStr as NSString).draw( - at: CGPoint(x: pageRect.width - margin - provSize.width, y: infoY), - withAttributes: infoAttrs - ) + + if cfg.includeInsulin && (!boluses.isEmpty || !smbs.isEmpty) { + y = sectionHdr("INSULIN DELIVERY", y: y + 2, m: m, w: r.width, cfg: cfg, ctx: ctx) + y = insulinSection(boluses: boluses, smbs: smbs, simpleVM: simpleVM, + stats: stats, cfg: cfg, y: y + 1, m: m, w: r.width, ctx: ctx) } - return headerH + 12 - } + if cfg.includeNutrition && !carbs.isEmpty { + y = sectionHdr("NUTRITION & MEALS", y: y + 2, m: m, w: r.width, cfg: cfg, ctx: ctx) + y = nutritionSection(carbs: carbs, stats: stats, cfg: cfg, y: y + 1, m: m, w: r.width, ctx: ctx) + } - @discardableResult - private static func drawPageContinuationHeader( - ctx: CGContext, pageRect: CGRect, - patientName: String, startDate: Date, endDate: Date - ) -> CGFloat { - let headerH: CGFloat = 32 - ctx.setFillColor(colorDark.cgColor) - ctx.fill(CGRect(x: 0, y: 0, width: pageRect.width, height: headerH)) - - let attrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 11), - .foregroundColor: UIColor.white, - ] - "CGM Report — \(patientName)".draw(at: CGPoint(x: margin, y: 9), withAttributes: attrs) - return headerH + 12 - } + let hasDevice = cfg.includeDevices && (!cfg.pumpDevice.isEmpty || !cfg.cgmDevice.isEmpty || !cfg.insulinType.isEmpty) + let hasSettings = cfg.includeTherapySettings && (!cfg.carbRatio.isEmpty || !cfg.isf.isEmpty || !cfg.basalRate.isEmpty || !cfg.targetGlucose.isEmpty) + + if hasDevice || hasSettings { + y = sectionHdr("SYSTEM & THERAPY SETTINGS", y: y + 2, m: m, w: r.width, cfg: cfg, ctx: ctx) + var gridItems: [(String, String)] = [] + if hasDevice { + if !cfg.pumpDevice.isEmpty { gridItems.append(("Pump", cfg.pumpDevice)) } + if !cfg.cgmDevice.isEmpty { gridItems.append(("CGM", cfg.cgmDevice)) } + if !cfg.insulinType.isEmpty { gridItems.append(("Insulin", cfg.insulinType)) } + } + if hasSettings { + if !cfg.carbRatio.isEmpty { gridItems.append(("CR", cfg.carbRatio)) } + if !cfg.isf.isEmpty { gridItems.append(("ISF", cfg.isf)) } + if !cfg.basalRate.isEmpty { gridItems.append(("Basal", formatBasalRateForDisplay(cfg.basalRate))) } + if !cfg.targetGlucose.isEmpty { gridItems.append(("Target", cfg.targetGlucose)) } + } + y = drawSettingsGrid(gridItems, x: m, y: y + 1, width: r.width - m * 2, cfg: cfg, ctx: ctx) + } - private static func drawFooter(ctx: CGContext, pageRect: CGRect, page: Int) { - let footerY = pageRect.height - 28 - ctx.setFillColor(colorLightGray.cgColor) - ctx.fill(CGRect(x: 0, y: footerY, width: pageRect.width, height: 28)) + if !cfg.notes.isEmpty { + y = drawNotesSection(cfg.notes, x: m, y: y + 2, width: r.width - m * 2, cfg: cfg, ctx: ctx) + } - let attrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 7.5), - .foregroundColor: UIColor.secondaryLabel, - ] - "LoopFollow CGM Report | For clinical use only | Targets 70–180 mg/dL" - .draw(at: CGPoint(x: margin, y: footerY + 8), withAttributes: attrs) - - let pageStr = "Page \(page)" - let pageSize = (pageStr as NSString).size(withAttributes: attrs) - (pageStr as NSString).draw( - at: CGPoint(x: pageRect.width - margin - pageSize.width, y: footerY + 8), - withAttributes: attrs - ) + if cfg.includeAGP, !agpData.isEmpty { + let agpAvail = r.height - y - 40 + if agpAvail >= 80 { + y = sectionHdr("AMBULATORY GLUCOSE PROFILE", y: y + 6, m: m, w: r.width, cfg: cfg, ctx: ctx) + let agpH = Swift.min(agpAvail - 20, 130) + drawAGP(agpData: agpData, x: m, y: y + 4, w: r.width - m * 2, h: agpH, cfg: cfg, ctx: ctx) + } + } + + drawFooter(ctx: ctx, r: r, cfg: cfg, stats: stats, page: 1) } - // MARK: - Section title + // MARK: - Hero header @discardableResult - private static func drawSectionTitle(_ title: String, y: CGFloat, in pageRect: CGRect, ctx: CGContext) -> CGFloat { - let attrs: [NSAttributedString.Key: Any] = [ - .font: sectionFont, - .foregroundColor: colorBlue, - ] - (title.uppercased() as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: attrs) + private static func drawHero(ctx: CGContext, r: CGRect, cfg: EndoReportConfig, stats: ReportStats) -> CGFloat { + let h: CGFloat = 90; let ac = accent(cfg); let ad = accentDark(cfg) + ctx.setFillColor(ac.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: h)) + ctx.setFillColor(ad.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: 21)) + + let a1: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8.5), .foregroundColor: C_WHITE.withAlphaComponent(0.8), .kern: 3.0] + "LOOP FOLLOW".draw(at: CGPoint(x: 26, y: 5), withAttributes: a1) + + let a2: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 21), .foregroundColor: C_WHITE] + "Endocrinologist Visit Report".draw(at: CGPoint(x: 26, y: 26), withAttributes: a2) + + let a3: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 9.5), .foregroundColor: C_WHITE.withAlphaComponent(0.82)] + "Automated Insulin Delivery Performance Summary".draw(at: CGPoint(x: 26, y: 52), withAttributes: a3) + + let df = DateFormatter(); df.dateFormat = "MMMM d, yyyy" + let ds = "\(df.string(from: cfg.startDate)) — \(df.string(from: cfg.endDate)) (\(Int(stats.days.rounded())) Days)" + let a4: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 9), .foregroundColor: C_WHITE.withAlphaComponent(0.68)] + ds.draw(at: CGPoint(x: 26, y: 68), withAttributes: a4) + + let a5: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 8.5), .foregroundColor: C_WHITE.withAlphaComponent(0.95)] + var lines: [String] = [] + if !cfg.patientName.isEmpty { lines.append("Patient: \(cfg.patientName)") } + if !cfg.providerName.isEmpty { lines.append("Provider: \(cfg.providerName)") } + if !cfg.dateOfBirth.isEmpty { lines.append("DOB: \(cfg.dateOfBirth)") } + if !cfg.aidSystem.isEmpty { lines.append("AID: \(cfg.aidSystem)") } + if !cfg.diagnosisDate.isEmpty { lines.append("Dx: \(cfg.diagnosisDate)") } + + for (i, l) in lines.enumerated() { + let sz = (l as NSString).size(withAttributes: a5) + (l as NSString).draw(at: CGPoint(x: r.width - 26 - sz.width, y: 24 + CGFloat(i) * 11.5), withAttributes: a5) + } + return h + } - let lineY = y + 14 - ctx.setStrokeColor(colorBorder.cgColor) - ctx.setLineWidth(0.5) - ctx.move(to: CGPoint(x: margin, y: lineY)) - ctx.addLine(to: CGPoint(x: pageRect.width - margin, y: lineY)) - ctx.strokePath() + // MARK: - Daily page header - return lineY + 8 + @discardableResult + private static func drawDailyPageHeader(ctx: CGContext, r: CGRect, cfg: EndoReportConfig, + page: Int, totalPages: Int) -> CGFloat + { + let h: CGFloat = 40; let ac = accent(cfg) + ctx.setFillColor(ac.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: h)) + let a1: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 12), .foregroundColor: C_WHITE] + "Daily Glucose Breakdown".draw(at: CGPoint(x: 28, y: 11), withAttributes: a1) + let sub = "Newest to Oldest • Page \(page) of \(totalPages)" + let a2: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 8), .foregroundColor: C_WHITE.withAlphaComponent(0.75)] + let sz = (sub as NSString).size(withAttributes: a2) + (sub as NSString).draw(at: CGPoint(x: r.width - 28 - sz.width, y: 14), withAttributes: a2) + return h } - // MARK: - Key metrics cards + // MARK: - Section header @discardableResult - private static func drawKeyMetrics(stats: SimpleStats, y: CGFloat, in pageRect: CGRect, ctx: CGContext) -> CGFloat { - let units = Storage.shared.units.value - let isMMOL = units == "mmol/L" - - func fmtGlucose(_ v: Double) -> String { - isMMOL ? String(format: "%.1f", v * 0.0555) : String(format: "%.0f", v) - } - - let cards: [(String, String, String)] = [ - ("eA1C", String(format: "%.1f%%", stats.eA1C), "Estimated A1C"), - ("TIR", { - // grab from TIR calculator average - let t = TIRCalculator.calculate(bgData: []) // placeholder — we draw this separately - return "—" - }(), "70–180 mg/dL"), - ("Avg Glucose", fmtGlucose(stats.avg), units), - ("CV", String(format: "%.1f%%", stats.cv), "SD \(fmtGlucose(stats.stdDev))"), - ("Sensor Active", String(format: "%.0f%%", stats.sensorPct), "\(stats.readingCount) readings"), + private static func sectionHdr(_ title: String, y: CGFloat, m: CGFloat, w: CGFloat, + cfg: EndoReportConfig, ctx: CGContext) -> CGFloat + { + ctx.setFillColor(accent(cfg).cgColor) + ctx.fill(CGRect(x: m, y: y, width: 3, height: 14)) + let a: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 9), .foregroundColor: accent(cfg), .kern: 0.6] + (title as NSString).draw(at: CGPoint(x: m + 8, y: y), withAttributes: a) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) + ctx.move(to: CGPoint(x: m, y: y + 15)); ctx.addLine(to: CGPoint(x: w - m, y: y + 15)); ctx.strokePath() + return y + 16 + } + + // MARK: - Stat card + + private static func statCard(_ label: String, val: String, x: CGFloat, y: CGFloat, + w: CGFloat, h: CGFloat, accent ac: Bool, + cfg: EndoReportConfig, ctx: CGContext) + { + let r = CGRect(x: x, y: y, width: w, height: h) + ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r) + if ac { + ctx.setFillColor(accent(cfg).withAlphaComponent(0.07).cgColor); ctx.fill(r) + ctx.setFillColor(accent(cfg).cgColor); ctx.fill(CGRect(x: x, y: y, width: 3, height: h)) + } + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r) + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.5] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 16), .foregroundColor: ac ? accent(cfg) : C_INK] + (label as NSString).draw(at: CGPoint(x: x + 8, y: y + 4), withAttributes: la) + (val as NSString).draw(at: CGPoint(x: x + 8, y: y + 14), withAttributes: va) + } + + // MARK: - TIR vertical bar + + private static func drawTIRBar(stats: ReportStats, + x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat, + cfg: EndoReportConfig, ctx: CGContext) + { + let r = CGRect(x: x, y: y, width: w, height: h) + ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r) + + let ta: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: C_SLATE] + "Time in Range".draw(at: CGPoint(x: x + 8, y: y + 6), withAttributes: ta) + + let targetLine1Y = y + h - 24 + let targetLine2Y = y + h - 12 + + // Reserve explicit space above target lines so the five legend labels stay comfortably separated. + let bx = x + 10 + let bw: CGFloat = 16 + let by = y + 22 + let legendTop = by + 1 + let legendBottom = targetLine1Y - 13 + let bh = max(legendBottom - by, 20) + + let segs: [(Double, UIColor, String)] = [ + (stats.veryHigh, C_VHIGH, "Very High"), (stats.high, C_HIGH, "High"), + (stats.inRange, C_IN, "In Range"), (stats.low, C_LOW, "Low"), + (stats.veryLow, C_VLOW, "Very Low"), ] + var sy = by - let cardW = (pageRect.width - margin * 2) / CGFloat(cards.count) - let cardH: CGFloat = 52 - let startX = margin - - for (i, card) in cards.enumerated() { - let cardX = startX + CGFloat(i) * cardW - let rect = CGRect(x: cardX, y: y, width: cardW - 4, height: cardH) - - // Background - ctx.setFillColor(colorLightGray.cgColor) - ctx.fill(rect) - ctx.setStrokeColor(colorBorder.cgColor) - ctx.setLineWidth(0.5) - ctx.stroke(rect) - - // Value - let valAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 17), - .foregroundColor: colorDark, - ] - let valSize = (card.1 as NSString).size(withAttributes: valAttrs) - let valX = cardX + (cardW - 4 - valSize.width) / 2 - (card.1 as NSString).draw(at: CGPoint(x: valX, y: y + 8), withAttributes: valAttrs) - - // Label - let lblAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 7.5), - .foregroundColor: UIColor.secondaryLabel, + for (pct, clr, _) in segs { + let sh = CGFloat(pct / 100) * bh + if sh > 0 { + ctx.setFillColor(clr.cgColor) + ctx.fill(CGRect(x: bx, y: sy, width: bw, height: sh)) + } + sy += sh + } + + // Always show all 5 zones regardless of percentage value. + let legendX = bx + bw + 8 + let legendSpacing = max((legendBottom - legendTop) / CGFloat(segs.count - 1), 8) + for (index, (pct, clr, label)) in segs.enumerated() { + let ps = String(format: "%.0f%%", pct) + let isTarget = (label == "In Range") + let pa: [NSAttributedString.Key: Any] = [ + .font: isTarget ? UIFont.boldSystemFont(ofSize: 7.5) : UIFont.systemFont(ofSize: 7), + .foregroundColor: isTarget ? accent(cfg) : C_SLATE, ] - let lblSize = (card.0 as NSString).size(withAttributes: lblAttrs) - (card.0 as NSString).draw( - at: CGPoint(x: cardX + (cardW - 4 - lblSize.width) / 2, y: y + 28), - withAttributes: lblAttrs - ) + let textStr = "\(label) \(ps)" + let textY = legendTop + CGFloat(index) * legendSpacing - let subSize = (card.2 as NSString).size(withAttributes: lblAttrs) - (card.2 as NSString).draw( - at: CGPoint(x: cardX + (cardW - 4 - subSize.width) / 2, y: y + 39), - withAttributes: lblAttrs - ) + ctx.setFillColor(clr.cgColor) + ctx.fill(CGRect(x: legendX, y: textY + 2, width: 6, height: 6)) + (textStr as NSString).draw(at: CGPoint(x: legendX + 9, y: textY), withAttributes: pa) } - return y + cardH + 12 + let na: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + "Target: 70-180".draw(at: CGPoint(x: x + 5, y: targetLine1Y), withAttributes: na) + "Time in Tight Range: 70-140".draw(at: CGPoint(x: x + 5, y: targetLine2Y), withAttributes: na) } - // MARK: - TIR bar + // MARK: - Time-of-day strip @discardableResult - private static func drawTIRBar(tirData: [TIRDataPoint], y: CGFloat, in pageRect: CGRect, ctx: CGContext) -> CGFloat { - guard let avg = tirData.first(where: { $0.period == .average }) else { return y } - - let barW = pageRect.width - margin * 2 - let barH: CGFloat = 20 - var x = margin - - let segments: [(CGFloat, UIColor)] = [ - (CGFloat(avg.veryLow / 100) * barW, colorVeryLow), - (CGFloat(avg.low / 100) * barW, colorLow), - (CGFloat(avg.inRange / 100) * barW, colorInRange), - (CGFloat(avg.high / 100) * barW, colorHigh), - (CGFloat(avg.veryHigh / 100) * barW, colorVeryHigh), - ] + private static func timeStrip(patterns: TimePatterns, cfg: EndoReportConfig, + y: CGFloat, m: CGFloat, w: CGFloat, ctx: CGContext) -> CGFloat + { + let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: C_INK] + "Glucose by Time of Day (\(cfg.units))".draw(at: CGPoint(x: m, y: y), withAttributes: ha) + + let periods = [patterns.night, patterns.earlyAM, patterns.morning, + patterns.afternoon, patterns.evening, patterns.late] + // Time range labels matching the GlycemicPatterns init hours + let timeRanges = ["00:00–03:00", "03:00–06:00", "06:00–12:00", + "12:00–17:00", "17:00–21:00", "21:00–24:00"] + + let cw = (w - m * 2) / CGFloat(periods.count) + let ch: CGFloat = 48 + let cy = y + 11 + + for (i, p) in periods.enumerated() { + let cx = m + CGFloat(i) * cw + let cardWidth = cw - 2 + let rr = CGRect(x: cx, y: cy, width: cardWidth, height: ch) + ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(rr) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(rr) + + let timeRange = timeRanges[i] + let topA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + let bottomA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 7), .foregroundColor: C_SLATE] + + let disp = p.count > 0 ? cfg.fmtBG(p.avg) : "—" + let vc: UIColor = p.count > 0 + ? (p.avg < 70 ? C_LOW : p.avg < 140 ? accent(cfg) : p.avg < 180 ? C_INK : C_HIGH) + : C_SLATE + let valueA: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 14), .foregroundColor: vc] + + let topSize = (timeRange as NSString).size(withAttributes: topA) + let valueSize = (disp as NSString).size(withAttributes: valueA) + let bottomSize = (p.label as NSString).size(withAttributes: bottomA) + + let topToValueGap: CGFloat = 3 + let valueToBottomGap: CGFloat = 2 + let stackHeight = topSize.height + topToValueGap + valueSize.height + valueToBottomGap + bottomSize.height + let startY = cy + (ch - stackHeight) / 2 + + let topX = cx + (cardWidth - topSize.width) / 2 + let valueX = cx + (cardWidth - valueSize.width) / 2 + let bottomX = cx + (cardWidth - bottomSize.width) / 2 + + (timeRange as NSString).draw(at: CGPoint(x: topX, y: startY), withAttributes: topA) + (disp as NSString).draw(at: CGPoint(x: valueX, y: startY + topSize.height + topToValueGap), withAttributes: valueA) + (p.label as NSString).draw(at: CGPoint(x: bottomX, y: startY + topSize.height + topToValueGap + valueSize.height + valueToBottomGap), withAttributes: bottomA) + } + return cy + ch + 2 + } - for (w, clr) in segments { - if w > 0 { - ctx.setFillColor(clr.cgColor) - ctx.fill(CGRect(x: x, y: y, width: w, height: barH)) - } - x += w - } - - // Labels inside bar - x = margin - let pcts = [avg.veryLow, avg.low, avg.inRange, avg.high, avg.veryHigh] - let clrs = [colorVeryLow, colorLow, colorInRange, colorHigh, colorVeryHigh] - for (i, pct) in pcts.enumerated() { - let w = CGFloat(pct / 100) * barW - if pct >= 5 { - let pctStr = String(format: "%.1f%%", pct) - let attrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 8), - .foregroundColor: UIColor.white, - ] - let sz = (pctStr as NSString).size(withAttributes: attrs) - (pctStr as NSString).draw( - at: CGPoint(x: x + (w - sz.width) / 2, y: y + (barH - sz.height) / 2), - withAttributes: attrs - ) - } - x += w + // MARK: - Insulin section + + @discardableResult + private static func insulinSection(boluses: [MainViewController.bolusGraphStruct], + smbs: [MainViewController.bolusGraphStruct], + simpleVM: SimpleStatsViewModel, stats _: ReportStats, + cfg: EndoReportConfig, y: CGFloat, m: CGFloat, w: CGFloat, + ctx: CGContext) -> CGFloat + { + let tdd = simpleVM.totalDailyDose ?? 0 + let basalPct = tdd > 0 ? (simpleVM.actualBasal ?? 0) / tdd * 100 : 0 + let bolusPct = tdd > 0 ? (simpleVM.avgBolus ?? 0) / tdd * 100 : 0 + let cards: [(String, String)] = [("AVG TDD", tdd > 0 ? String(format: "%.1fU", tdd) : "—"), + ("BASAL", basalPct > 0 ? String(format: "%.0f%%", basalPct) : "—"), + ("BOLUS", bolusPct > 0 ? String(format: "%.0f%%", bolusPct) : "—")] + let cw = (w - m * 2) / 3 - 4; let ch: CGFloat = 36 + for (i, c) in cards.enumerated() { + let cx = m + CGFloat(i) * (cw + 4) + let r2 = CGRect(x: cx, y: y, width: cw, height: ch) + ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r2) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r2) + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.4] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 16), .foregroundColor: C_INK] + (c.0 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 4), withAttributes: la) + (c.1 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 14), withAttributes: va) } + let ty = y + ch + 2 + let total = (boluses + smbs).map { $0.value }.reduce(0,+) + let rows: [(String, String)] = [ + ("Correction Boluses", "\(boluses.count)"), + ("SMB / Auto-Corrections", "\(smbs.count)"), + ("Total Bolus Insulin", String(format: "%.1f U", total)), + ("Programmed Basal", simpleVM.programmedBasal != nil ? String(format: "%.2f U/day", simpleVM.programmedBasal!) : "—"), + ("Actual Basal", simpleVM.actualBasal != nil ? String(format: "%.2f U/day", simpleVM.actualBasal!) : "—"), + ] + return metricTable(rows, x: m, y: ty, width: w - m * 2, cfg: cfg, ctx: ctx) + } + + // MARK: - Nutrition section - // Legend - let legendY = y + barH + 4 - let legendItems: [(String, UIColor)] = [ - ("Very Low <54", colorVeryLow), - ("Low 54-69", colorLow), - ("In Range 70-180", colorInRange), - ("High 181-250", colorHigh), - ("Very High >250", colorVeryHigh), + @discardableResult + private static func nutritionSection(carbs: [MainViewController.carbGraphStruct], + stats: ReportStats, cfg _: EndoReportConfig, + y: CGFloat, m: CGFloat, w: CGFloat, ctx: CGContext) -> CGFloat + { + let total = carbs.map { $0.value }.reduce(0,+) + let cards: [(String, String)] = [ + ("DAILY CARBS", String(format: "%.0fg", total / stats.days)), + ("MEALS LOGGED", "\(carbs.count)"), + ("PER MEAL AVG", String(format: "%.0fg", carbs.isEmpty ? 0 : total / Double(carbs.count))), ] - let itemW = barW / CGFloat(legendItems.count) - for (i, item) in legendItems.enumerated() { - let ix = margin + CGFloat(i) * itemW - ctx.setFillColor(item.1.cgColor) - ctx.fill(CGRect(x: ix, y: legendY, width: 8, height: 8)) - let attrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 7), - .foregroundColor: UIColor.secondaryLabel, - ] - (item.0 as NSString).draw(at: CGPoint(x: ix + 10, y: legendY), withAttributes: attrs) + let cw = (w - m * 2) / 3 - 4; let ch: CGFloat = 36 + for (i, c) in cards.enumerated() { + let cx = m + CGFloat(i) * (cw + 4) + let r2 = CGRect(x: cx, y: y, width: cw, height: ch) + ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r2) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r2) + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.4] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 16), .foregroundColor: C_INK] + (c.0 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 4), withAttributes: la) + (c.1 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 14), withAttributes: va) } - - return legendY + 16 + return y + ch + 2 } - // MARK: - TIR table + // MARK: - Tables @discardableResult - private static func drawTIRTable(tirData: [TIRDataPoint], y: CGFloat, in pageRect: CGRect, ctx: CGContext) -> CGFloat { - let cols: [CGFloat] = [100, 100, 70, 80, 70] - let headers = ["Zone", "Range", "Your %", "ADA Target", "Status"] - let rows: [(String, String, Double, String, Bool)] = [ - ("Very Low", "< 54 mg/dL", tirData.first(where:{$0.period == .average})?.veryLow ?? 0, "< 1%", (tirData.first(where:{$0.period == .average})?.veryLow ?? 99) < 1), - ("Low", "54–69 mg/dL", tirData.first(where:{$0.period == .average})?.low ?? 0, "< 4%", (tirData.first(where:{$0.period == .average})?.low ?? 99) < 4), - ("In Range", "70–180 mg/dL", tirData.first(where:{$0.period == .average})?.inRange ?? 0, "> 70%", (tirData.first(where:{$0.period == .average})?.inRange ?? 0) >= 70), - ("High", "181–250 mg/dL",tirData.first(where:{$0.period == .average})?.high ?? 0, "< 25%", (tirData.first(where:{$0.period == .average})?.high ?? 99) < 25), - ("Very High", "> 250 mg/dL", tirData.first(where:{$0.period == .average})?.veryHigh ?? 0,"< 5%", (tirData.first(where:{$0.period == .average})?.veryHigh ?? 99) < 5), - ] - let rowColors = [colorVeryLow, colorLow, colorInRange, colorHigh, colorVeryHigh] - - let rowH: CGFloat = 16 - var curY = y - var curX = margin - - // Header row - ctx.setFillColor(colorDark.cgColor) - let totalW = cols.reduce(0, +) - ctx.fill(CGRect(x: margin, y: curY, width: totalW, height: rowH)) - for (i, h) in headers.enumerated() { - let attrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 8), - .foregroundColor: UIColor.white, - ] - (h as NSString).draw(at: CGPoint(x: curX + 4, y: curY + 4), withAttributes: attrs) - curX += cols[i] - } - curY += rowH - - // Data rows - for (ri, row) in rows.enumerated() { - ctx.setFillColor((ri % 2 == 0 ? UIColor.white : colorLightGray).cgColor) - ctx.fill(CGRect(x: margin, y: curY, width: totalW, height: rowH)) - - // Zone color swatch in first cell - ctx.setFillColor(rowColors[ri].cgColor) - ctx.fill(CGRect(x: margin, y: curY, width: cols[0], height: rowH)) - - let cells = [row.0, row.1, String(format: "%.1f%%", row.2), row.3, row.4 ? "✓" : "↑"] - curX = margin - for (ci, cell) in cells.enumerated() { - let attrs: [NSAttributedString.Key: Any] = [ - .font: ci == 0 ? UIFont.boldSystemFont(ofSize: 8) : UIFont.systemFont(ofSize: 8), - .foregroundColor: ci == 0 ? UIColor.white : colorDark, - ] - (cell as NSString).draw(at: CGPoint(x: curX + 4, y: curY + 4), withAttributes: attrs) - curX += cols[ci] + private static func metricTable(_ rows: [(String, String)], x: CGFloat, y: CGFloat, + width: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat + { + let tw = width; let hh: CGFloat = 12; let rh: CGFloat = 11; var cy = y + ctx.setFillColor(accent(cfg).withAlphaComponent(0.10).cgColor) + ctx.fill(CGRect(x: x, y: cy, width: tw, height: hh)) + let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg), .kern: 0.4] + "METRIC".draw(at: CGPoint(x: x + 6, y: cy + 1), withAttributes: ha) + "VALUE".draw(at: CGPoint(x: x + tw * 0.58 + 6, y: cy + 1), withAttributes: ha) + cy += hh + for (i, row) in rows.enumerated() { + ctx.setFillColor((i % 2 == 0 ? C_WHITE : C_CLOUD).cgColor) + ctx.fill(CGRect(x: x, y: cy, width: tw, height: rh)) + let ka: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 7), .foregroundColor: C_INK] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg)] + (row.0 as NSString).draw(at: CGPoint(x: x + 6, y: cy + 1), withAttributes: ka) + (row.1 as NSString).draw(at: CGPoint(x: x + tw * 0.58 + 6, y: cy + 1), withAttributes: va) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.3) + ctx.move(to: CGPoint(x: x, y: cy + rh)); ctx.addLine(to: CGPoint(x: x + tw, y: cy + rh)); ctx.strokePath() + cy += rh + } + return cy + 1 + } + + // Dynamic settings table to handle multi-line text input neatly + @discardableResult + private static func settingsTable(_ rows: [(String, String)], x: CGFloat, y: CGFloat, + width: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat + { + let tw = width + let headerH: CGFloat = 12 + var cy = y + + ctx.setFillColor(accent(cfg).withAlphaComponent(0.10).cgColor) + ctx.fill(CGRect(x: x, y: cy, width: tw, height: headerH)) + + let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg), .kern: 0.4] + "THERAPY SETTING & VALUES".draw(at: CGPoint(x: x + 6, y: cy + 3), withAttributes: ha) + cy += headerH + + for (i, row) in rows.enumerated() { + let lines = row.1.components(separatedBy: "\n").filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + let rh = 11.0 + CGFloat(lines.count) * 10.5 + 4.0 + + ctx.setFillColor((i % 2 == 0 ? C_WHITE : C_CLOUD).cgColor) + ctx.fill(CGRect(x: x, y: cy, width: tw, height: rh)) + + let ka: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 7.5), .foregroundColor: C_SLATE] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: accent(cfg)] + + (row.0 as NSString).draw(at: CGPoint(x: x + 6, y: cy + 3.5), withAttributes: ka) + + var ly = cy + 12.5 + for line in lines { + (line as NSString).draw(at: CGPoint(x: x + 6, y: ly), withAttributes: va) + ly += 10.5 } - // Border - ctx.setStrokeColor(colorBorder.cgColor) + ctx.setStrokeColor(C_BORDER.cgColor) ctx.setLineWidth(0.3) - ctx.stroke(CGRect(x: margin, y: curY, width: totalW, height: rowH)) - curY += rowH - } + ctx.move(to: CGPoint(x: x, y: cy + rh)) + ctx.addLine(to: CGPoint(x: x + tw, y: cy + rh)) + ctx.strokePath() - return curY + 12 + cy += rh + } + return cy + 2 } - // MARK: - AGP chart (drawn natively with Core Graphics) + // MARK: - AGP - @discardableResult - private static func drawAGPChart(agpData: [AGPDataPoint], y: CGFloat, in pageRect: CGRect, ctx: CGContext) -> CGFloat { - guard !agpData.isEmpty else { return y } - - let chartW: CGFloat = pageRect.width - margin * 2 - let chartH: CGFloat = 140 - let chartX: CGFloat = margin - let chartY: CGFloat = y - - // Background - ctx.setFillColor(UIColor(white: 0.98, alpha: 1).cgColor) - ctx.fill(CGRect(x: chartX, y: chartY, width: chartW, height: chartH)) - - // Target zones - let yRange: CGFloat = 350 // 40–400 - let yMin: CGFloat = 40 - func glucoseToY(_ g: Double) -> CGFloat { - chartY + chartH - (CGFloat(g) - yMin) / yRange * chartH - } - func timeToX(_ minutes: Int) -> CGFloat { - chartX + CGFloat(minutes) / (24 * 60) * chartW - } - - // Very low zone - ctx.setFillColor(colorVeryLow.withAlphaComponent(0.08).cgColor) - ctx.fill(CGRect(x: chartX, y: glucoseToY(54), width: chartW, height: glucoseToY(40) - glucoseToY(54))) - - // Low zone - ctx.setFillColor(colorLow.withAlphaComponent(0.08).cgColor) - ctx.fill(CGRect(x: chartX, y: glucoseToY(70), width: chartW, height: glucoseToY(54) - glucoseToY(70))) - - // High zone - ctx.setFillColor(colorHigh.withAlphaComponent(0.08).cgColor) - ctx.fill(CGRect(x: chartX, y: glucoseToY(250), width: chartW, height: glucoseToY(180) - glucoseToY(250))) - - // Target lines - ctx.setStrokeColor(colorLow.withAlphaComponent(0.6).cgColor) - ctx.setLineWidth(0.8) - ctx.setLineDash(phase: 0, lengths: [4, 3]) - ctx.move(to: CGPoint(x: chartX, y: glucoseToY(70))) - ctx.addLine(to: CGPoint(x: chartX + chartW, y: glucoseToY(70))) - ctx.strokePath() + private static func drawAGP(agpData: [AGPDataPoint], x: CGFloat, y: CGFloat, + w: CGFloat, h: CGFloat, cfg: EndoReportConfig, ctx: CGContext) + { + guard !agpData.isEmpty else { return } + let lPad: CGFloat = 26; let bPad: CGFloat = 24 + let cw = w - lPad; let ch = h - bPad + let cx = x + lPad; let cy = y - ctx.setStrokeColor(colorHigh.withAlphaComponent(0.6).cgColor) - ctx.move(to: CGPoint(x: chartX, y: glucoseToY(180))) - ctx.addLine(to: CGPoint(x: chartX + chartW, y: glucoseToY(180))) - ctx.strokePath() - ctx.setLineDash(phase: 0, lengths: []) + ctx.setFillColor(UIColor(white: 0.985, alpha: 1).cgColor) + ctx.fill(CGRect(x: cx, y: cy, width: cw, height: ch)) + + let bgMin: CGFloat = 40; let bgRng: CGFloat = 320 + func gy(_ g: Double) -> CGFloat { cy + ch - (CGFloat(g) - bgMin) / bgRng * ch } + func tx(_ mins: Int) -> CGFloat { cx + CGFloat(mins) / (24 * 60) * cw } - // 5–95 band - let p5Path = CGMutablePath() - let p95Path = CGMutablePath() - var bandPath = CGMutablePath() + ctx.setFillColor(C_IN.withAlphaComponent(0.07).cgColor) + ctx.fill(CGRect(x: cx, y: gy(180), width: cw, height: gy(70) - gy(180))) + ctx.setLineDash(phase: 0, lengths: [3, 2]) + for (val, clr) in [(70.0, C_LOW), (180.0, C_HIGH)] { + ctx.setStrokeColor(clr.withAlphaComponent(0.5).cgColor); ctx.setLineWidth(0.6) + ctx.move(to: CGPoint(x: cx, y: gy(val))); ctx.addLine(to: CGPoint(x: cx + cw, y: gy(val))); ctx.strokePath() + } + ctx.setLineDash(phase: 0, lengths: []) + + var band = CGMutablePath() for (i, pt) in agpData.enumerated() { - let x = timeToX(pt.timeOfDay) - let y5 = glucoseToY(pt.p5) - let y95 = glucoseToY(pt.p95) - if i == 0 { - p5Path.move(to: CGPoint(x: x, y: y5)) - p95Path.move(to: CGPoint(x: x, y: y95)) - bandPath.move(to: CGPoint(x: x, y: y95)) - } else { - p5Path.addLine(to: CGPoint(x: x, y: y5)) - p95Path.addLine(to: CGPoint(x: x, y: y95)) - bandPath.addLine(to: CGPoint(x: x, y: y95)) - } + let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p95)); i == 0 ? band.move(to: p) : band.addLine(to: p) } - // Close band with p5 reversed for pt in agpData.reversed() { - bandPath.addLine(to: CGPoint(x: timeToX(pt.timeOfDay), y: glucoseToY(pt.p5))) + band.addLine(to: CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p5))) } - bandPath.closeSubpath() - ctx.setFillColor(colorBlue.withAlphaComponent(0.12).cgColor) - ctx.addPath(bandPath) - ctx.fillPath() + band.closeSubpath() + ctx.setFillColor(accent(cfg).withAlphaComponent(0.10).cgColor); ctx.addPath(band); ctx.fillPath() - // 25–75 band - var iqrPath = CGMutablePath() + var iqr = CGMutablePath() for (i, pt) in agpData.enumerated() { - let x = timeToX(pt.timeOfDay) - if i == 0 { iqrPath.move(to: CGPoint(x: x, y: glucoseToY(pt.p75))) } - else { iqrPath.addLine(to: CGPoint(x: x, y: glucoseToY(pt.p75))) } + let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p75)); i == 0 ? iqr.move(to: p) : iqr.addLine(to: p) } for pt in agpData.reversed() { - iqrPath.addLine(to: CGPoint(x: timeToX(pt.timeOfDay), y: glucoseToY(pt.p25))) + iqr.addLine(to: CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p25))) } - iqrPath.closeSubpath() - ctx.setFillColor(colorBlue.withAlphaComponent(0.25).cgColor) - ctx.addPath(iqrPath) - ctx.fillPath() + iqr.closeSubpath() + ctx.setFillColor(accent(cfg).withAlphaComponent(0.25).cgColor); ctx.addPath(iqr); ctx.fillPath() - // Median line - ctx.setStrokeColor(colorBlue.cgColor) - ctx.setLineWidth(1.8) + ctx.setStrokeColor(accent(cfg).cgColor); ctx.setLineWidth(1.6) var first = true for pt in agpData { - let pt2D = CGPoint(x: timeToX(pt.timeOfDay), y: glucoseToY(pt.p50)) - if first { ctx.move(to: pt2D); first = false } - else { ctx.addLine(to: pt2D) } + let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p50)); first ? ctx.move(to: p) : ctx.addLine(to: p); first = false } ctx.strokePath() - // X axis labels - let axisAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 7), - .foregroundColor: UIColor.secondaryLabel, - ] - for h in stride(from: 0, through: 24, by: 3) { - let lbl = String(format: "%02d:00", h) - let lx = timeToX(h * 60) - let lsize = (lbl as NSString).size(withAttributes: axisAttrs) - (lbl as NSString).draw(at: CGPoint(x: lx - lsize.width / 2, y: chartY + chartH + 2), withAttributes: axisAttrs) - - // Vertical grid line - ctx.setStrokeColor(colorBorder.cgColor) - ctx.setLineWidth(0.3) - ctx.move(to: CGPoint(x: lx, y: chartY)) - ctx.addLine(to: CGPoint(x: lx, y: chartY + chartH)) - ctx.strokePath() + let axA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + for bg in [70, 140, 180, 250] { + let ly = gy(Double(bg)); guard ly >= cy, ly <= cy + ch else { continue } + let lbl = cfg.isMMOL ? String(format: "%.1f", Double(bg) * 0.0555) : "\(bg)" + let lsz = (lbl as NSString).size(withAttributes: axA) + (lbl as NSString).draw(at: CGPoint(x: x + lPad - lsz.width - 3, y: ly - lsz.height / 2), withAttributes: axA) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.25) + ctx.move(to: CGPoint(x: cx, y: ly)); ctx.addLine(to: CGPoint(x: cx + cw, y: ly)); ctx.strokePath() } - // Y axis labels - for bg in [54, 70, 140, 180, 250, 350] { - let ly = glucoseToY(Double(bg)) - let lbl = "\(bg)" - let lsz = (lbl as NSString).size(withAttributes: axisAttrs) - (lbl as NSString).draw(at: CGPoint(x: chartX - lsz.width - 3, y: ly - lsz.height / 2), withAttributes: axisAttrs) + for h2 in stride(from: 0, through: 24, by: 3) { + let lx = tx(h2 * 60) + let lbl = String(format: "%02d:00", h2) + let lsz = (lbl as NSString).size(withAttributes: axA) + let dx = Swift.max(cx, Swift.min(cx + cw - lsz.width, lx - lsz.width / 2)) + (lbl as NSString).draw(at: CGPoint(x: dx, y: cy + ch + 3), withAttributes: axA) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.25) + ctx.move(to: CGPoint(x: lx, y: cy)); ctx.addLine(to: CGPoint(x: lx, y: cy + ch)); ctx.strokePath() } - // Border - ctx.setStrokeColor(colorBorder.cgColor) - ctx.setLineWidth(0.5) - ctx.stroke(CGRect(x: chartX, y: chartY, width: chartW, height: chartH)) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) + ctx.stroke(CGRect(x: cx, y: cy, width: cw, height: ch)) + + let lgA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + let lgItems: [(String, UIColor, Bool)] = [("Median", accent(cfg), false), + ("25–75th", accent(cfg).withAlphaComponent(0.4), true), + ("5–95th", accent(cfg).withAlphaComponent(0.18), true)] + var lgX = cx + cw + for item in lgItems.reversed() { + let lsz = (item.0 as NSString).size(withAttributes: lgA) + lgX -= lsz.width + (item.0 as NSString).draw(at: CGPoint(x: lgX, y: cy + ch + 11), withAttributes: lgA) + lgX -= 15 + item.2 ? { ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x: lgX, y: cy + ch + 12, width: 12, height: 8)) }() + : { ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x: lgX, y: cy + ch + 15, width: 12, height: 2)) }() + lgX -= 5 + } + } - // Legend - let lgY = chartY + chartH + 12 - let legendItems: [(String, UIColor, Bool)] = [ - ("Median", colorBlue, false), - ("25–75th %ile", colorBlue.withAlphaComponent(0.4), true), - ("5–95th %ile", colorBlue.withAlphaComponent(0.18), true), - ] - var lgX = chartX - for item in legendItems { - ctx.setFillColor(item.1.cgColor) - ctx.fill(CGRect(x: lgX, y: lgY, width: item.2 ? 14 : 20, height: item.2 ? 8 : 2.5)) - if !item.2 { - // line for median - ctx.setFillColor(item.1.cgColor) - ctx.fill(CGRect(x: lgX, y: lgY + 2, width: 20, height: 2)) + @discardableResult + private static func drawSettingsGrid(_ items: [(String, String)], x: CGFloat, y: CGFloat, width: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat { + let count = CGFloat(items.count) + guard count > 0 else { return y } + let spacing: CGFloat = 4 + let cw = (width - (count - 1) * spacing) / count + var maxH: CGFloat = 0 + + for (i, item) in items.enumerated() { + let cx = x + CGFloat(i) * (cw + spacing) + let lines = item.1.components(separatedBy: "\n").filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + let h = 12.0 + CGFloat(lines.count) * 9.5 + 4.0 + maxH = max(maxH, h) + + ctx.setFillColor(C_CLOUD.cgColor) + ctx.fill(CGRect(x: cx, y: y, width: cw, height: h)) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(CGRect(x: cx, y: y, width: cw, height: h)) + + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.8), .foregroundColor: C_SLATE] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg)] + + (item.0 as NSString).draw(at: CGPoint(x: cx + 4, y: y + 2.5), withAttributes: la) + var ly = y + 10.5 + for line in lines { + (line as NSString).draw(at: CGPoint(x: cx + 4, y: ly), withAttributes: va) + ly += 9.5 } - let lgAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 7.5), - .foregroundColor: UIColor.secondaryLabel, - ] - let lsz = (item.0 as NSString).size(withAttributes: lgAttrs) - (item.0 as NSString).draw(at: CGPoint(x: lgX + (item.2 ? 18 : 24), y: lgY), withAttributes: lgAttrs) - lgX += lsz.width + (item.2 ? 18 : 24) + 16 } + return y + maxH + 4 + } + + @discardableResult + private static func drawNotesSection(_ notes: String, x: CGFloat, y: CGFloat, width: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat { + let headerY = sectionHdr("NOTES & OBSERVATIONS", y: y, m: x, w: width + x * 2, cfg: cfg, ctx: ctx) + let font = UIFont.systemFont(ofSize: 8) + let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: C_INK] - return lgY + 20 + let textRect = CGRect(x: x + 6, y: headerY + 4, width: width - 12, height: 1000) + let size = (notes as NSString).boundingRect(with: textRect.size, options: .usesLineFragmentOrigin, attributes: attributes, context: nil).size + + let drawRect = CGRect(x: x + 6, y: headerY + 4, width: width - 12, height: size.height) + (notes as NSString).draw(in: drawRect, withAttributes: attributes) + + return headerY + 4 + size.height + 4 } - // MARK: - Daily summary table + // MARK: - Format helpers - @discardableResult - private static func drawDailyTable(bgData: [ShareGlucoseData], y: CGFloat, in pageRect: CGRect, ctx: CGContext) -> CGFloat { - // Group by day - let calendar = dateTimeUtils.displayCalendar() - var byDay: [String: [Double]] = [:] - let df = DateFormatter() - df.dateFormat = "yyyy-MM-dd" + private static func formatBasalRateForDisplay(_ input: String) -> String { + let lines = input.components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } - for r in bgData { - let d = df.string(from: Date(timeIntervalSince1970: r.date)) - byDay[d, default: []].append(Double(r.sgv)) - } - - let cols: [CGFloat] = [90, 60, 50, 50, 55, 60, 60] - let headers = ["Date", "Avg", "SD", "Min", "Max", "TIR %", "Readings"] - let totalW = cols.reduce(0, +) - let rowH: CGFloat = 14 - var curY = y - var curX = margin - - // Header - ctx.setFillColor(colorDark.cgColor) - ctx.fill(CGRect(x: margin, y: curY, width: totalW, height: rowH)) - for (i, h) in headers.enumerated() { - let attrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 7.5), - .foregroundColor: UIColor.white, - ] - (h as NSString).draw(at: CGPoint(x: curX + 3, y: curY + 3), withAttributes: attrs) - curX += cols[i] + // Helper to extract a double from a string that might contain units or other text + func extractDouble(_ s: String) -> Double? { + let cleaned = s.replacingOccurrences(of: ",", with: ".") + .components(separatedBy: CharacterSet(charactersIn: "0123456789.").inverted) + .joined() + return Double(cleaned) } - curY += rowH - let dayAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 7.5), - .foregroundColor: colorDark, - ] - let df2 = DateFormatter() - df2.dateFormat = "EEE MMM d" - - for (ri, day) in byDay.keys.sorted().enumerated() { - let vals = byDay[day]! - let n = Double(vals.count) - let mean = vals.reduce(0, +) / n - let sd = sqrt(vals.map { ($0 - mean) * ($0 - mean) }.reduce(0, +) / n) - let tir = vals.filter { $0 >= 70 && $0 <= 180 }.count - let tirPct = Double(tir) / n * 100 - - let date = df.date(from: day) ?? Date() - let cells = [ - df2.string(from: date), - String(format: "%.0f", mean), - String(format: "%.0f", sd), - String(format: "%.0f", vals.min() ?? 0), - String(format: "%.0f", vals.max() ?? 0), - String(format: "%.0f%%", tirPct), - "\(vals.count)", - ] + if input.contains("=") || (input.contains(":") && lines.count > 1) { + var formatted: [String] = [] + for line in lines { + let sep = line.contains("=") ? "=" : ":" + let parts = line.components(separatedBy: sep).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + if parts.count >= 2, let last = parts.last, let rate = extractDouble(last) { + let timeKey = parts.dropLast().joined(separator: sep) + formatted.append("\(timeKey) = \(String(format: "%.2f", rate))") + } else { + formatted.append(line) + } + } + return formatted.isEmpty ? input : formatted.joined(separator: "\n") + } - ctx.setFillColor((ri % 2 == 0 ? UIColor.white : colorLightGray).cgColor) - ctx.fill(CGRect(x: margin, y: curY, width: totalW, height: rowH)) + if let value = extractDouble(input) { + return String(format: "%.2f U/hr", value) + } + return input + } - curX = margin - for (ci, cell) in cells.enumerated() { - (cell as NSString).draw(at: CGPoint(x: curX + 3, y: curY + 3), withAttributes: dayAttrs) - curX += cols[ci] - } - ctx.setStrokeColor(colorBorder.cgColor) - ctx.setLineWidth(0.3) - ctx.stroke(CGRect(x: margin, y: curY, width: totalW, height: rowH)) - curY += rowH + // MARK: - Basal Profile Helpers + + static func calculateDailyProgrammedBasal(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 = (i < sortedProfile.count - 1) ? Double(sortedProfile[i + 1].timeAsSeconds) : Double(secondsInDay) + let durationHours = (nextTime - currentTime) / 3600.0 + totalBasal += current.value * durationHours } - return curY + 14 + return totalBasal } - // MARK: - Insulin & carb summary + // MARK: - Day row + + private static func drawDayRow(ctx: CGContext, x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat, + day: String, dayData: DayData, cfg: EndoReportConfig, + basalProfile: [MainViewController.basalProfileStruct]) + { + ctx.setFillColor(C_WHITE.cgColor); ctx.fill(CGRect(x: x, y: y, width: w, height: h)) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) + ctx.stroke(CGRect(x: x, y: y, width: w, height: h)) + + ctx.setFillColor(cfg.accentColor.cgColor) + ctx.fill(CGRect(x: x, y: y, width: 3, height: h)) + + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + let df2 = DateFormatter(); df2.dateFormat = "EEEE, MMM d, yyyy" + let date = df.date(from: day) ?? Date() + let dlA: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 9), .foregroundColor: C_INK] + df2.string(from: date).draw(at: CGPoint(x: x + 10, y: y + 5), withAttributes: dlA) + + // Statistics Container on the Right + let statsW: CGFloat = 115 + let statsX = x + w - statsW + let boxRect = CGRect(x: statsX, y: y + 1, width: statsW - 1, height: h - 2) + ctx.setFillColor(C_CLOUD.cgColor) + ctx.fill(boxRect) + + ctx.setStrokeColor(C_BORDER.cgColor) + ctx.setLineWidth(0.4) + ctx.move(to: CGPoint(x: statsX, y: y + 1)) + ctx.addLine(to: CGPoint(x: statsX, y: y + h - 1)) + ctx.strokePath() - @discardableResult - private static func drawInsulinCarbSummary( - boluses: [MainViewController.bolusGraphStruct], - smbs: [MainViewController.bolusGraphStruct], - carbs: [MainViewController.carbGraphStruct], - stats: SimpleStats, - y: CGFloat, - in pageRect: CGRect, - ctx: CGContext - ) -> CGFloat { - let days = max(stats.sensorPct / 100 * 14, 1) - let totalBolus = boluses.map { $0.value }.reduce(0, +) - + smbs.map { $0.value }.reduce(0, +) - let totalCarbs = carbs.map { $0.value }.reduce(0, +) - - let cards: [(String, String, String)] = [ - ("Avg Daily Bolus", String(format: "%.1f U", totalBolus / days), "Total \(String(format: "%.1f", totalBolus)) U"), - ("Bolus Count", "\(boluses.count + smbs.count)", "Over report period"), - ("Avg Daily Carbs", String(format: "%.0f g", totalCarbs / days), "Total \(String(format: "%.0f", totalCarbs)) g"), - ("Carb Entries", "\(carbs.count)", "Logged entries"), - ] + let vals = dayData.bg.map { Double($0.sgv) } + if !vals.isEmpty { + let n = Double(vals.count) + let avg = vals.reduce(0,+) / n + let tir = Double(vals.filter { $0 >= 70 && $0 <= 180 }.count) / n * 100 + let totalInsulin = dayData.bolus.map { $0.value }.reduce(0, +) + dayData.smb.map { $0.value }.reduce(0, +) + let dailyProgrammedBasal = calculateDailyProgrammedBasal(basalProfile: basalProfile) + + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: C_SLATE] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: C_INK] + let tirC: UIColor = tir >= 70 ? C_IN : tir >= 50 ? C_HIGH : C_VLOW + let tirA: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: tirC] + + let padding: CGFloat = 8 + let col2X = statsX + statsW / 2 + + // Row 1: Avg & TIR + "Avg BG".draw(at: CGPoint(x: statsX + padding, y: y + 8), withAttributes: la) + cfg.fmtBG(avg).draw(at: CGPoint(x: statsX + padding, y: y + 15), withAttributes: va) + + "TIR".draw(at: CGPoint(x: col2X, y: y + 8), withAttributes: la) + String(format: "%.0f%%", tir).draw(at: CGPoint(x: col2X, y: y + 15), withAttributes: tirA) + + // Divider + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.3) + ctx.move(to: CGPoint(x: statsX + 5, y: y + 30)) + ctx.addLine(to: CGPoint(x: x + w - 5, y: y + 30)) + ctx.strokePath() - let cardW = (pageRect.width - margin * 2) / CGFloat(cards.count) - let cardH: CGFloat = 48 - - for (i, card) in cards.enumerated() { - let cardX = margin + CGFloat(i) * cardW - let rect = CGRect(x: cardX, y: y, width: cardW - 4, height: cardH) - ctx.setFillColor(colorLightGray.cgColor) - ctx.fill(rect) - ctx.setStrokeColor(colorBorder.cgColor) - ctx.setLineWidth(0.5) - ctx.stroke(rect) - - let valAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 14), - .foregroundColor: colorDark, - ] - let valSz = (card.1 as NSString).size(withAttributes: valAttrs) - (card.1 as NSString).draw(at: CGPoint(x: cardX + (cardW - 4 - valSz.width) / 2, y: y + 6), withAttributes: valAttrs) + // Row 2: Bolus & Basal + "Bolus Total".draw(at: CGPoint(x: statsX + padding, y: y + 36), withAttributes: la) + String(format: "%.1f U", totalInsulin).draw(at: CGPoint(x: statsX + padding, y: y + 43), withAttributes: va) - let lblAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 7.5), - .foregroundColor: UIColor.secondaryLabel, - ] - let lsz = (card.0 as NSString).size(withAttributes: lblAttrs) - (card.0 as NSString).draw(at: CGPoint(x: cardX + (cardW - 4 - lsz.width) / 2, y: y + 24), withAttributes: lblAttrs) + "Basal Sched".draw(at: CGPoint(x: col2X, y: y + 36), withAttributes: la) + String(format: "%.1f U", dailyProgrammedBasal).draw(at: CGPoint(x: col2X, y: y + 43), withAttributes: va) + + // Divider + ctx.move(to: CGPoint(x: statsX + 5, y: y + 58)) + ctx.addLine(to: CGPoint(x: x + w - 5, y: y + 58)) + ctx.strokePath() + + // Row 3: Coverage + "Data Coverage".draw(at: CGPoint(x: statsX + padding, y: y + 64), withAttributes: la) + let coverage = String(format: "%.0f%%", Double(vals.count) / 2.88) + "\(vals.count) pts (\(coverage))".draw(at: CGPoint(x: statsX + padding, y: y + 71), withAttributes: va) + } + + let chartX = x + 10; let chartW = w - 140 + let chartY = y + 26 + let axisLabelArea: CGFloat = 12 + let chartH = h - 26 - axisLabelArea + + guard !dayData.bg.isEmpty else { return } + + ctx.saveGState() + ctx.clip(to: CGRect(x: chartX, y: chartY, width: chartW, height: chartH)) + + let bgMin: CGFloat = 40; let bgMax: CGFloat = 320; let bgRng = bgMax - bgMin + func gy(_ bg: Double) -> CGFloat { chartY + chartH - (CGFloat(bg) - bgMin) / bgRng * chartH } + func tx(_ ts: Double) -> CGFloat { + let cal = dateTimeUtils.displayCalendar() + let d = Date(timeIntervalSince1970: ts) + let c = cal.dateComponents([.hour, .minute], from: d) + let min = Double((c.hour ?? 0) * 60 + (c.minute ?? 0)) + return chartX + CGFloat(min / (24 * 60)) * chartW + } + + ctx.setFillColor(C_IN.withAlphaComponent(0.06).cgColor) + ctx.fill(CGRect(x: chartX, y: gy(180), width: chartW, height: gy(70) - gy(180))) - let ssz = (card.2 as NSString).size(withAttributes: lblAttrs) - (card.2 as NSString).draw(at: CGPoint(x: cardX + (cardW - 4 - ssz.width) / 2, y: y + 34), withAttributes: lblAttrs) + ctx.setLineDash(phase: 0, lengths: [2, 2]); ctx.setLineWidth(0.4) + ctx.setStrokeColor(C_LOW.withAlphaComponent(0.4).cgColor) + ctx.move(to: CGPoint(x: chartX, y: gy(70))); ctx.addLine(to: CGPoint(x: chartX + chartW, y: gy(70))); ctx.strokePath() + ctx.setStrokeColor(C_HIGH.withAlphaComponent(0.4).cgColor) + ctx.move(to: CGPoint(x: chartX, y: gy(180))); ctx.addLine(to: CGPoint(x: chartX + chartW, y: gy(180))); ctx.strokePath() + ctx.setLineDash(phase: 0, lengths: []) + + ctx.setStrokeColor(C_BORDER.withAlphaComponent(0.5).cgColor); ctx.setLineWidth(0.25) + for h2 in stride(from: 3, through: 21, by: 3) { + let hx = chartX + CGFloat(h2) / 24 * chartW + ctx.move(to: CGPoint(x: hx, y: chartY)); ctx.addLine(to: CGPoint(x: hx, y: chartY + chartH)); ctx.strokePath() + } + + if !dayData.basal.isEmpty { + let bH = chartH * 0.25; let bY = chartY + chartH - bH + let sorted = dayData.basal.sorted { $0.date < $1.date } + let maxR = Swift.max(sorted.map { $0.basalRate }.max() ?? 1, 0.01) + + var path = CGMutablePath(); var first = true + for pt in sorted { + let px = tx(pt.date); let py = bY + bH - CGFloat(pt.basalRate / maxR) * bH + first ? path.move(to: CGPoint(x: px, y: py)) : path.addLine(to: CGPoint(x: px, y: py)); first = false + } + if let last = sorted.last { + path.addLine(to: CGPoint(x: tx(last.date), y: bY + bH)) + path.addLine(to: CGPoint(x: chartX, y: bY + bH)); path.closeSubpath() + ctx.setFillColor(C_BASAL.withAlphaComponent(0.15).cgColor); ctx.addPath(path); ctx.fillPath() + } + + var lp = CGMutablePath(); first = true + for (index, pt) in sorted.enumerated() { + let px = tx(pt.date); let py = bY + bH - CGFloat(pt.basalRate / maxR) * bH + first ? lp.move(to: CGPoint(x: px, y: py)) : lp.addLine(to: CGPoint(x: px, y: py)); first = false + + if pt.basalRate > 0.01 { + let nextX = index < sorted.count - 1 ? tx(sorted[index + 1].date) : (chartX + chartW) + if (nextX - px) > 14 { + let rateStr = String(format: "%.2f", pt.basalRate) + let rA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 4.2), .foregroundColor: C_BASAL] + rateStr.draw(at: CGPoint(x: px + 1, y: py - 7), withAttributes: rA) + } + } + } + ctx.setStrokeColor(C_BASAL.cgColor); ctx.setLineWidth(0.9); ctx.addPath(lp); ctx.strokePath() } - return y + cardH + 14 + // Draw Carbs as small green diamonds/circles at the top of the chart + for carb in dayData.carbs { + let cx = tx(carb.date) + let cy = chartY + 4 + ctx.setFillColor(C_CARB.cgColor) + ctx.fillEllipse(in: CGRect(x: cx - 2.5, y: cy - 2.5, width: 5, height: 5)) + } + + for smb in dayData.smb { + let bx = tx(smb.date); let bh2 = max(CGFloat(Swift.min(smb.value / 15, 1)) * (chartH * 0.35), 2.5) + ctx.setFillColor(C_SMB.cgColor) + ctx.fill(CGRect(x: bx - 2, y: chartY + chartH - bh2, width: 4, height: bh2)) + } + + for bolus in dayData.bolus { + let bx = tx(bolus.date); let bh2 = max(CGFloat(Swift.min(bolus.value / 15, 1)) * (chartH * 0.4), 3.0) + ctx.setFillColor(C_BOLUS.cgColor) + ctx.fill(CGRect(x: bx - 2.5, y: chartY + chartH - bh2, width: 5, height: bh2)) + } + + let sortedBG = dayData.bg.sorted(by: { $0.date < $1.date }) + for r in sortedBG { + let rx = tx(r.date); let ry = gy(Double(r.sgv)) + ctx.setFillColor(bgColor(Double(r.sgv)).cgColor) + ctx.fillEllipse(in: CGRect(x: rx - 1.6, y: ry - 1.6, width: 3.2, height: 3.2)) + } + + ctx.restoreGState() + + let axA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: C_SLATE] + let axisLabelY = chartY + chartH + 4 + for h2 in [0, 6, 12, 18, 24] { + let hx = chartX + CGFloat(h2) / 24 * chartW + let lbl = String(format: "%02d", h2) + let sz = (lbl as NSString).size(withAttributes: axA) + (lbl as NSString).draw(at: CGPoint(x: hx - sz.width / 2, y: axisLabelY), withAttributes: axA) + } + + // Legend moved to top area next to date + let lgA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: C_SLATE] + var lgX = x + 120 + for (lbl, clr) in [("● BG", C_IN), ("● Carbs", C_CARB), ("▮ Bolus", C_BOLUS), ("▮ SMB", C_SMB), ("— Basal", C_BASAL)] { + let a: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: clr] + (lbl as NSString).draw(at: CGPoint(x: lgX, y: y + 7), withAttributes: a) + lgX += (lbl as NSString).size(withAttributes: lgA).width + 5 + if lgX > statsX - 4 { break } + } + } + + // MARK: - Footer + + private static func drawFooter(ctx: CGContext, r: CGRect, cfg _: EndoReportConfig, + stats: ReportStats, page: Int) + { + let fy = r.height - 28 + ctx.setFillColor(C_INK.cgColor); ctx.fill(CGRect(x: 0, y: fy, width: r.width, height: 28)) + let a: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_WHITE.withAlphaComponent(0.5)] + "Loop Follow — for informational purposes only. Not a substitute for professional medical advice." + .draw(at: CGPoint(x: 30, y: fy + 4), withAttributes: a) + let df = DateFormatter(); df.dateFormat = "MMM d, yyyy" + let meta = "Generated: \(df.string(from: Date())) • \(Int(stats.days.rounded())) Days • \(stats.readingCount) readings • Page \(page)" + let msz = (meta as NSString).size(withAttributes: a) + (meta as NSString).draw(at: CGPoint(x: r.width - 30 - msz.width, y: fy + 4), withAttributes: a) } } diff --git a/LoopFollow/Stats/EndoReportView.swift b/LoopFollow/Stats/EndoReportView.swift index 9481257ed..75b17d9d2 100644 --- a/LoopFollow/Stats/EndoReportView.swift +++ b/LoopFollow/Stats/EndoReportView.swift @@ -836,167 +836,77 @@ class NightscoutProfileFetcher: ObservableObject { let units: String } - // Lenient local structs — all fields optional to survive any Nightscout variant - private struct LenientProfile: Decodable { - let defaultProfile: String? - let units: String? - let store: [String: LenientStore]? - } - - private struct LenientStore: Decodable { - let units: String? - let basal: [Entry]? - let sens: [Entry]? - let carbratio: [Entry]? - let target_low: [Entry]? - let target_high: [Entry]? - - struct Entry: Decodable { - let value: Double? - let time: String? - } - } - func fetch(completion: @escaping (FetchedSettings?) -> Void) { isFetching = true error = nil success = false - let baseURL = Storage.shared.url.value - let token = Storage.shared.token.value - - guard let url = NightscoutUtils.constructURL( - baseURL: baseURL, - token: token, - endpoint: "/api/v1/profile/current.json", + NightscoutUtils.executeRequest( + eventType: .profile, parameters: [:] - ) else { - DispatchQueue.main.async { - self.isFetching = false - self.error = "Could not construct Nightscout URL. Check your site address in settings." - } - completion(nil) - return - } - - var request = URLRequest(url: url) - request.cachePolicy = .reloadIgnoringLocalCacheData - - URLSession.shared.dataTask(with: request) { [weak self] data, _, networkError in + ) { [weak self] (result: Result) in DispatchQueue.main.async { guard let self else { return } self.isFetching = false - if let networkError { - self.error = networkError.localizedDescription + switch result { + case let .failure(err): + self.error = err.localizedDescription completion(nil) - return - } - guard let data, !data.isEmpty else { - self.error = "Empty response from Nightscout. Check your URL and token." - completion(nil) - return - } + case let .success(profile): + let store = profile.store[profile.defaultProfile] + ?? profile.store["default"] + ?? profile.store["Default"] + ?? profile.store.values.first - // Try lenient decode first - let decoder = JSONDecoder() - guard let profile = try? decoder.decode(LenientProfile.self, from: data) else { - // Fall back to raw JSON dictionary parsing - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let storeDict = json["store"] as? [String: Any], - let firstStore = storeDict.values.first as? [String: Any] - else { - self.error = "Could not parse Nightscout profile. Tap to retry." + guard let s = store else { + self.error = "No profile store found in Nightscout response." completion(nil) return } - self.parseRawStore(firstStore, completion: completion) - return - } - - // Pick the right store - let storeName = profile.defaultProfile ?? "default" - let store = profile.store?[storeName] - ?? profile.store?["default"] - ?? profile.store?["Default"] - ?? profile.store?.values.first - guard let s = store else { - self.error = "No profile store found in Nightscout response." - completion(nil) - return - } + let isMMOL = s.units.lowercased().contains("mmol") - let isMMOL = (s.units ?? profile.units ?? "mg/dL").lowercased().contains("mmol") - let result = self.buildSettings(store: s, isMMOL: isMMOL) - self.success = true - completion(result) - } - }.resume() - } + func fmtValue(_ value: Double) -> String { + if value == floor(value) { + return String(format: "%.0f", value) + } + let raw = String(format: "%.2f", value) + return raw.replacingOccurrences(of: "\\.?0+$", with: "", options: .regularExpression) + } - // Parse from lenient typed struct - private func buildSettings(store: LenientStore, isMMOL: Bool) -> FetchedSettings { - func fmt(_ v: Double) -> String { - v == floor(v) ? String(format: "%.0f", v) : - String(format: "%.2f", v).replacingOccurrences(of: #"\.?0+$"#, with: "", options: .regularExpression) - } + func fmtSchedule(_ entries: [T], + value: (T) -> Double, + time: (T) -> String) -> String + { + if entries.count == 1 { + return fmtValue(value(entries[0])) + } + // Output joined by newlines so it populates the multi-line UI cleanly + return entries.map { + "\(time($0)) = \(fmtValue(value($0)))" + }.joined(separator: "\n") + } - func schedule(_ entries: [LenientStore.Entry]?) -> String { - guard let entries = entries, !entries.isEmpty else { return "" } - let valid = entries.compactMap { e -> (String, Double)? in - guard let v = e.value, let t = e.time else { return nil } - return (t, v) - } - if valid.count == 1 { return fmt(valid[0].1) } - return valid.map { "\($0.0) = \(fmt($0.1))" }.joined(separator: "\n") - } + let cr = fmtSchedule(s.carbratio, value: { $0.value }, time: { $0.time }) + let isf = fmtSchedule(s.sens, value: { $0.value }, time: { $0.time }) + let bas = fmtSchedule(s.basal, value: { $0.value }, time: { $0.time }) - let fmtTarget = isMMOL ? "%.1f" : "%.0f" - let tLow = store.target_low?.first?.value.map { String(format: fmtTarget, $0) } ?? "" - let tHigh = store.target_high?.first?.value.map { String(format: fmtTarget, $0) } ?? "" - - return FetchedSettings( - carbRatio: schedule(store.carbratio), - isf: schedule(store.sens), - basalRate: schedule(store.basal), - targetLow: tLow, - targetHigh: tHigh, - units: isMMOL ? "mmol/L" : "mg/dL" - ) - } + let targetLow = s.target_low?.first.map { String(format: isMMOL ? "%.1f" : "%.0f", $0.value) } ?? "" + let targetHigh = s.target_high?.first.map { String(format: isMMOL ? "%.1f" : "%.0f", $0.value) } ?? "" - // Last-resort raw dictionary parser - private func parseRawStore(_ raw: [String: Any], completion: @escaping (FetchedSettings?) -> Void) { - func entries(_ key: String) -> [(String, Double)] { - guard let arr = raw[key] as? [[String: Any]] else { return [] } - return arr.compactMap { e in - guard let v = e["value"] as? Double, let t = e["time"] as? String else { return nil } - return (t, v) + self.success = true + completion(FetchedSettings( + carbRatio: cr, + isf: isf, + basalRate: bas, + targetLow: targetLow, + targetHigh: targetHigh, + units: isMMOL ? "mmol/L" : "mg/dL" + )) + } } } - func schedule(_ key: String) -> String { - let e = entries(key) - guard !e.isEmpty else { return "" } - if e.count == 1 { return String(format: "%.2g", e[0].1) } - return e.map { "\($0.0) = \(String(format: "%.2g", $0.1))" }.joined(separator: "\n") - } - - let units = (raw["units"] as? String ?? "mg/dL") - let isMMOL = units.lowercased().contains("mmol") - let fmtT = isMMOL ? "%.1f" : "%.0f" - let tLow = (raw["target_low"] as? [[String: Any]])?.first?["value"] as? Double - let tHigh = (raw["target_high"] as? [[String: Any]])?.first?["value"] as? Double - - success = true - completion(FetchedSettings( - carbRatio: schedule("carbratio"), - isf: schedule("sens"), - basalRate: schedule("basal"), - targetLow: tLow.map { String(format: fmtT, $0) } ?? "", - targetHigh: tHigh.map { String(format: fmtT, $0) } ?? "", - units: isMMOL ? "mmol/L" : "mg/dL" - )) } }