polscm32 aka Marvout 1 год назад
Родитель
Сommit
829d8f4bee

+ 157 - 73
FreeAPS/Sources/Modules/Stat/StatStateModel+Setup/BolusStatsSetup.swift

@@ -1,41 +1,50 @@
 import CoreData
 import Foundation
 
-/// Represents statistical data about bolus insulin delivery for a specific day
+/// Represents statistical data about bolus insulin for a specific time period
 struct BolusStats: Identifiable {
     let id = UUID()
     /// The date representing this time period
     let date: Date
-    /// Total amount of manual boluses (excluding SMB and external)
+    /// Total manual bolus insulin in units
     let manualBolus: Double
-    /// Total amount of Super Micro Boluses (SMB)
+    /// Total SMB insulin in units
     let smb: Double
-    /// Total amount of external boluses (e.g., from pump directly)
+    /// Total external bolus insulin in units
     let external: Double
 }
 
 extension Stat.StateModel {
-    /// Initializes and fetches bolus statistics
+    /// Sets up bolus statistics by fetching and processing bolus data
     ///
     /// This function:
-    /// 1. Fetches bolus records from CoreData
-    /// 2. Groups and processes the records into bolus statistics
-    /// 3. Updates the bolusStats array on the main thread
+    /// 1. Fetches hourly and daily bolus statistics asynchronously
+    /// 2. Updates the state model with the fetched statistics on the main actor
+    /// 3. Calculates and caches initial daily averages
     func setupBolusStats() {
         Task {
-            let stats = await fetchBolusStats()
+            let (hourly, daily) = await fetchBolusStats()
+
             await MainActor.run {
-                self.bolusStats = stats
+                self.hourlyBolusStats = hourly
+                self.dailyBolusStats = daily
             }
+
+            // Initially calculate and cache daily averages
+            await calculateAndCacheBolusAverages()
         }
     }
 
-    /// Fetches and processes bolus statistics for a specific date range
-    /// - Returns: Array of BolusStats containing daily bolus statistics
-    private func fetchBolusStats() async -> [BolusStats] {
-        let calendar = Calendar.current
-
-        // Fetch bolus records from Core Data
+    /// Fetches and processes bolus statistics from Core Data
+    /// - Returns: A tuple containing hourly and daily bolus statistics arrays
+    ///
+    /// This function:
+    /// 1. Fetches bolus entries from Core Data
+    /// 2. Groups entries by hour and day
+    /// 3. Calculates total insulin for each time period
+    /// 4. Returns the processed statistics as (hourly: [BolusStats], daily: [BolusStats])
+    private func fetchBolusStats() async -> (hourly: [BolusStats], daily: [BolusStats]) {
+        // Fetch PumpEventStored entries from Core Data
         let results = await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: BolusStored.self,
             onContext: bolusTaskContext,
@@ -45,82 +54,157 @@ extension Stat.StateModel {
             batchSize: 100
         )
 
-        return await bolusTaskContext.perform {
-            guard let fetchedResults = results as? [BolusStored] else { return [] }
-
-            // Group boluses by day or hour depending on selected duration
-            let groupedByTime = Dictionary(grouping: fetchedResults) { bolus -> Date in
-                guard let timestamp = bolus.pumpEvent?.timestamp else { return Date() }
+        // Variables to hold the results
+        var hourlyStats: [BolusStats] = []
+        var dailyStats: [BolusStats] = []
 
-                if self.selectedDurationForInsulinStats == .Day {
-                    // For Day view, group by hour
-                    let components = calendar.dateComponents([.year, .month, .day, .hour], from: timestamp)
-                    return calendar.date(from: components) ?? Date()
-                } else {
-                    // For other views, group by day
-                    return calendar.startOfDay(for: timestamp)
-                }
+        // Process CoreData results within the context's thread
+        await bolusTaskContext.perform {
+            guard let fetchedResults = results as? [BolusStored] else {
+                return
             }
 
-            // Get all unique time points
-            let timePoints = groupedByTime.keys.sorted()
-
-            // Calculate totals for each time point
-            return timePoints.map { timePoint in
-                let boluses = groupedByTime[timePoint, default: []]
+            let calendar = Calendar.current
 
-                // Calculate total manual boluses (excluding SMB and external)
-                let manualBolus = boluses
-                    .filter { !($0.isExternal || $0.isSMB) }
-                    .reduce(0.0) { $0 + (($1.amount as? Decimal) ?? 0).doubleValue }
+            // Group entries by hour for hourly statistics
+            let hourlyGrouped = Dictionary(grouping: fetchedResults) { entry in
+                let components = calendar.dateComponents(
+                    [.year, .month, .day, .hour],
+                    from: entry.pumpEvent?.timestamp ?? Date()
+                )
+                return calendar.date(from: components) ?? Date()
+            }
 
-                // Calculate total SMB
-                let smb = boluses
-                    .filter { $0.isSMB }
-                    .reduce(0.0) { $0 + (($1.amount as? Decimal) ?? 0).doubleValue }
+            // Group entries by day for daily statistics
+            let dailyGrouped = Dictionary(grouping: fetchedResults) { entry in
+                calendar.startOfDay(for: entry.pumpEvent?.timestamp ?? Date())
+            }
 
-                // Calculate total external boluses
-                let external = boluses
-                    .filter { $0.isExternal }
-                    .reduce(0.0) { $0 + (($1.amount as? Decimal) ?? 0).doubleValue }
+            // Process hourly stats
+            hourlyStats = hourlyGrouped.keys.sorted().map { timePoint in
+                let entries = hourlyGrouped[timePoint, default: []]
+                return BolusStats(
+                    date: timePoint,
+                    manualBolus: entries.reduce(0.0) { sum, entry in
+                        if !entry.isSMB, !entry.isExternal {
+                            return sum + (entry.amount?.doubleValue ?? 0)
+                        }
+                        return sum
+                    },
+                    smb: entries.reduce(0.0) { sum, entry in
+                        if entry.isSMB {
+                            return sum + (entry.amount?.doubleValue ?? 0)
+                        }
+                        return sum
+                    },
+                    external: entries.reduce(0.0) { sum, entry in
+                        if entry.isExternal {
+                            return sum + (entry.amount?.doubleValue ?? 0)
+                        }
+                        return sum
+                    }
+                )
+            }
 
+            // Process daily stats
+            dailyStats = dailyGrouped.keys.sorted().map { timePoint in
+                let entries = dailyGrouped[timePoint, default: []]
                 return BolusStats(
                     date: timePoint,
-                    manualBolus: manualBolus,
-                    smb: smb,
-                    external: external
+                    manualBolus: entries.reduce(0.0) { sum, entry in
+                        if !entry.isSMB, !entry.isExternal {
+                            return sum + (entry.amount?.doubleValue ?? 0)
+                        }
+                        return sum
+                    },
+                    smb: entries.reduce(0.0) { sum, entry in
+                        if entry.isSMB {
+                            return sum + (entry.amount?.doubleValue ?? 0)
+                        }
+                        return sum
+                    },
+                    external: entries.reduce(0.0) { sum, entry in
+                        if entry.isExternal {
+                            return sum + (entry.amount?.doubleValue ?? 0)
+                        }
+                        return sum
+                    }
                 )
             }
         }
+
+        return (hourlyStats, dailyStats)
+    }
+
+    /// Calculates and caches the daily averages of bolus insulin
+    ///
+    /// This function:
+    /// 1. Groups bolus statistics by day
+    /// 2. Calculates average total, carb and correction bolus for each day
+    /// 3. Caches the results for later use
+    ///
+    /// This only needs to be called once during subscribe.
+    private func calculateAndCacheBolusAverages() async {
+        let calendar = Calendar.current
+
+        // Calculate averages in context
+        let dailyAverages = await bolusTaskContext.perform { [dailyBolusStats] in
+            // Group by days
+            let groupedByDay = Dictionary(grouping: dailyBolusStats) { stat in
+                calendar.startOfDay(for: stat.date)
+            }
+
+            // Calculate averages for each day
+            var averages: [Date: (Double, Double, Double)] = [:]
+            for (day, stats) in groupedByDay {
+                let total = stats.reduce((0.0, 0.0, 0.0)) { acc, stat in
+                    (acc.0 + stat.manualBolus, acc.1 + stat.smb, acc.2 + stat.external)
+                }
+                let count = Double(stats.count)
+                averages[day] = (total.0 / count, total.1 / count, total.2 / count)
+            }
+            return averages
+        }
+
+        // Update cache on main thread
+        await MainActor.run {
+            self.bolusAveragesCache = dailyAverages
+        }
     }
 
-    /// Calculates the average daily insulin amounts for manual boluses, SMB (Super Micro Boluses), and external boluses
-    /// within the specified date range
+    /// Returns the average bolus values for the given date range from the cache
+    /// - Parameter range: A tuple containing the start and end dates to get averages for
+    /// - Returns: A tuple containing the average total, carb and correction bolus values for the date range
+    func getCachedBolusAverages(for range: (start: Date, end: Date)) -> (manual: Double, smb: Double, external: Double) {
+        return calculateBolusAveragesForDateRange(from: range.start, to: range.end)
+    }
+
+    /// Calculates the average bolus values for a given date range
     /// - Parameters:
-    ///   - startDate: The beginning date of the period to calculate averages for (inclusive)
-    ///   - endDate: The ending date of the period to calculate averages for (inclusive)
-    /// - Returns: A tuple containing three values:
-    ///   - manual: Average daily amount of manual boluses
-    ///   - smb: Average daily amount of Super Micro Boluses (SMB)
-    ///   - external: Average daily amount of external boluses (entered directly on pump)
-    /// - Note: Returns (0, 0, 0) if no data exists for the specified date range
-    func calculateAverageBolus(from startDate: Date, to endDate: Date) -> (manual: Double, smb: Double, external: Double) {
-        let visibleStats = bolusStats.filter { stat in
-            stat.date >= startDate && stat.date <= endDate
+    ///   - startDate: The start date of the range to calculate averages for
+    ///   - endDate: The end date of the range to calculate averages for
+    /// - Returns: A tuple containing the average total, carb and correction bolus values for the date range
+    func calculateBolusAveragesForDateRange(
+        from startDate: Date,
+        to endDate: Date
+    ) -> (manual: Double, smb: Double, external: Double) {
+        // Filter cached values to only include those within the date range
+        let relevantStats = bolusAveragesCache.filter { date, _ in
+            date >= startDate && date <= endDate
         }
 
-        guard !visibleStats.isEmpty else { return (0, 0, 0) }
+        // Return zeros if no data exists for the range
+        guard !relevantStats.isEmpty else { return (0, 0, 0) }
+
+        // Calculate total bolus across all days
+        let total = relevantStats.values.reduce((0.0, 0.0, 0.0)) { acc, avg in
+            (acc.0 + avg.0, acc.1 + avg.1, acc.2 + avg.2)
+        }
 
-        let count = Double(visibleStats.count)
-        let manualSum = visibleStats.reduce(0.0) { $0 + $1.manualBolus }
-        let smbSum = visibleStats.reduce(0.0) { $0 + $1.smb }
-        let externalSum = visibleStats.reduce(0.0) { $0 + $1.external }
+        // Calculate averages by dividing totals by number of days
+        let count = Double(relevantStats.count)
 
-        return (
-            manualSum / count,
-            smbSum / count,
-            externalSum / count
-        )
+        return (total.0 / count, total.1 / count, total.2 / count)
     }
 }
 

+ 5 - 0
FreeAPS/Sources/Modules/Stat/StatStateModel.swift

@@ -75,6 +75,11 @@ extension Stat {
         var dailyMealStats: [MealStats] = []
         var dailyAveragesCache: [Date: (carbs: Double, fat: Double, protein: Double)] = [:]
 
+        // Cache for Bolus Stats
+        var hourlyBolusStats: [BolusStats] = []
+        var dailyBolusStats: [BolusStats] = []
+        var bolusAveragesCache: [Date: (manual: Double, smb: Double, external: Double)] = [:]
+
         // Selected Duration for Glucose Stats
         var selectedDurationForGlucoseStats: StatsTimeInterval = .Day {
             didSet {

+ 7 - 6
FreeAPS/Sources/Modules/Stat/View/StatRootView.swift

@@ -202,11 +202,13 @@ extension Stat {
                     }
 
                 case .bolusDistribution:
+                    // TODO: -
                     var hasBolusData: Bool {
-                        state.bolusStats.contains { $0.manualBolus > 0 || $0.smb > 0 || $0.external > 0 }
+                        state.dailyBolusStats.contains { $0.manualBolus > 0 || $0.smb > 0 || $0.external > 0 }
                     }
 
-                    if state.bolusStats.isEmpty || !hasBolusData {
+                    // TODO: -
+                    if state.dailyBolusStats.isEmpty || !hasBolusData {
                         ContentUnavailableView(
                             "No Bolus Data",
                             systemImage: "cross.vial",
@@ -215,10 +217,9 @@ extension Stat {
                     } else {
                         BolusStatsView(
                             selectedDuration: $state.selectedDurationForInsulinStats,
-                            bolusStats: state.bolusStats,
-                            calculateAverages: { start, end in
-                                await state.calculateAverageBolus(from: start, to: end)
-                            }
+                            bolusStats: state.selectedDurationForInsulinStats == .Day ?
+                                state.hourlyBolusStats : state.dailyBolusStats,
+                            state: state
                         )
                     }
                 }

+ 103 - 69
FreeAPS/Sources/Modules/Stat/View/ViewElements/BolusStatsView.swift

@@ -4,30 +4,31 @@ import SwiftUI
 struct BolusStatsView: View {
     @Binding var selectedDuration: Stat.StateModel.StatsTimeInterval
     let bolusStats: [BolusStats]
-    let calculateAverages: @Sendable(Date, Date) async -> (manual: Double, smb: Double, external: Double)
+    let state: Stat.StateModel
 
-    @State private var scrollPosition = Date()
+    @State private var scrollPosition = Date() // gets updated in onAppear block
     @State private var selectedDate: Date?
     @State private var currentAverages: (manual: Double, smb: Double, external: Double) = (0, 0, 0)
     @State private var updateTimer = Stat.UpdateTimer()
-    @State private var isScrolling = false
 
+    /// Returns the time interval length for the visible domain based on selected duration
     private var visibleDomainLength: TimeInterval {
         switch selectedDuration {
-        case .Day: return 24 * 3600 // 1 day
-        case .Week: return 7 * 24 * 3600 // 1 week
-        case .Month: return 30 * 24 * 3600 // 1 month
-        case .Total: return 90 * 24 * 3600 // 3 months
+        case .Day: return 24 * 3600 // One day in seconds
+        case .Week: return 7 * 24 * 3600 // One week in seconds
+        case .Month: return 30 * 24 * 3600 // One month in seconds
+        case .Total: return 90 * 24 * 3600 // Three months in seconds
         }
     }
 
+    /// Calculates the visible date range based on scroll position and domain length
     private var visibleDateRange: (start: Date, end: Date) {
-        let halfDomain = visibleDomainLength / 2
-        let start = scrollPosition.addingTimeInterval(-halfDomain)
-        let end = scrollPosition.addingTimeInterval(halfDomain)
+        let start = scrollPosition // Current scroll position marks the start
+        let end = start.addingTimeInterval(visibleDomainLength)
         return (start, end)
     }
 
+    /// Returns the appropriate date format style based on the selected time interval
     private var dateFormat: Date.FormatStyle {
         switch selectedDuration {
         case .Day:
@@ -41,86 +42,115 @@ struct BolusStatsView: View {
         }
     }
 
+    /// Returns DateComponents for aligning dates based on the selected duration
     private var alignmentComponents: DateComponents {
         switch selectedDuration {
         case .Day:
-            return DateComponents(hour: 0) // Align to start of day
+            return DateComponents(hour: 0) // Align to midnight
         case .Week:
-            return DateComponents(weekday: 2) // 2 = Monday in Calendar
+            return DateComponents(weekday: 2) // Monday is weekday 2
         case .Month,
              .Total:
-            return DateComponents(day: 1) // Align to first day of month
+            return DateComponents(day: 1) // First day of month
         }
     }
 
+    /// Returns bolus statistics for a specific date
     private func getBolusForDate(_ date: Date) -> BolusStats? {
         bolusStats.first { stat in
             Calendar.current.isDate(stat.date, inSameDayAs: date)
         }
     }
 
+    /// Updates the current averages for bolus insulin based on the visible date range
     private func updateAverages() {
-        Task.detached(priority: .userInitiated) {
-            let dateRange = await MainActor.run { visibleDateRange }
-            let averages = await calculateAverages(dateRange.start, dateRange.end)
-
-            await MainActor.run {
-                currentAverages = averages
-            }
-        }
+        currentAverages = state.getCachedBolusAverages(for: visibleDateRange)
     }
 
-    private func formatVisibleDateRange(showTimeRange: Bool = false) -> String {
+    /// Formats the visible date range into a human-readable string
+    private func formatVisibleDateRange() -> String {
         let start = visibleDateRange.start
         let end = visibleDateRange.end
         let calendar = Calendar.current
+        let today = Date()
 
-        switch selectedDuration {
-        case .Day:
-            let today = Date()
-            let isToday = calendar.isDate(start, inSameDayAs: today)
-            let isYesterday = calendar.isDate(start, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!)
+        let timeFormat = start.formatted(.dateTime.hour().minute())
 
-            if isToday || isYesterday, !showTimeRange {
-                return isToday ? "Today" : "Yesterday"
-            }
+        // Special handling for Day view with relative dates
+        if selectedDuration == .Day {
+            let startDateText: String
+            let endDateText: String
 
-            let timeRange =
-                "\(start.formatted(.dateTime.hour(.twoDigits(amPM: .wide)))) - \(end.formatted(.dateTime.hour(.twoDigits(amPM: .wide))))"
+            // Format start date
+            if calendar.isDate(start, inSameDayAs: today) {
+                startDateText = "Today"
+            } else if calendar.isDate(start, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!) {
+                startDateText = "Yesterday"
+            } else if calendar.isDate(start, inSameDayAs: calendar.date(byAdding: .day, value: 1, to: today)!) {
+                startDateText = "Tomorrow"
+            } else {
+                startDateText = start.formatted(.dateTime.day().month())
+            }
 
-            if isToday {
-                return "Today, \(timeRange)"
-            } else if isYesterday {
-                return "Yesterday, \(timeRange)"
+            // Format end date
+            if calendar.isDate(end, inSameDayAs: today) {
+                endDateText = "Today"
+            } else if calendar.isDate(end, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!) {
+                endDateText = "Yesterday"
+            } else if calendar.isDate(end, inSameDayAs: calendar.date(byAdding: .day, value: 1, to: today)!) {
+                endDateText = "Tomorrow"
             } else {
-                return "\(start.formatted(.dateTime.month().day())), \(timeRange)"
+                endDateText = end.formatted(.dateTime.day().month())
             }
 
-        default:
-            return "\(start.formatted(.dateTime.month().day())) - \(end.formatted(.dateTime.month().day()))"
+            // If start and end are on the same day, show date only once
+            if calendar.isDate(start, inSameDayAs: end) {
+                return "\(startDateText), \(timeFormat) - \(end.formatted(.dateTime.hour().minute()))"
+            }
+
+            return "\(startDateText), \(timeFormat) - \(endDateText), \(end.formatted(.dateTime.hour().minute()))"
+        }
+
+        // Standard format for other views
+        return "\(start.formatted()) - \(end.formatted())"
+    }
+
+    /// Returns the initial scroll position date based on the selected duration
+    private func getInitialScrollPosition() -> Date {
+        let calendar = Calendar.current
+        let now = Date()
+
+        switch selectedDuration {
+        case .Day:
+            return calendar.date(byAdding: .day, value: -1, to: now)!
+        case .Week:
+            return calendar.date(byAdding: .day, value: -7, to: now)!
+        case .Month:
+            return calendar.date(byAdding: .month, value: -1, to: now)!
+        case .Total:
+            return calendar.date(byAdding: .month, value: -3, to: now)!
         }
     }
 
     var body: some View {
         VStack(alignment: .leading, spacing: 8) {
             statsView
-
             chartsView
         }
-
         .onAppear {
+            scrollPosition = getInitialScrollPosition()
             updateAverages()
         }
         .onChange(of: scrollPosition) {
-            isScrolling = true
             updateTimer.scheduleUpdate {
                 updateAverages()
-                isScrolling = false
             }
         }
         .onChange(of: selectedDuration) {
-            updateAverages()
-            scrollPosition = Date()
+            Task {
+                scrollPosition = getInitialScrollPosition()
+                updateAverages()
+            }
         }
     }
 
@@ -167,36 +197,37 @@ struct BolusStatsView: View {
 
             Spacer()
 
-            Text(formatVisibleDateRange(showTimeRange: isScrolling))
+            Text(formatVisibleDateRange())
                 .font(.subheadline)
                 .foregroundStyle(.secondary)
         }
     }
 
     private var chartsView: some View {
-        Chart {
-            ForEach(bolusStats) { stat in
-                // Manual Bolus (Bottom)
-                BarMark(
-                    x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
-                    y: .value("Amount", stat.manualBolus)
-                )
-                .foregroundStyle(by: .value("Type", "Manual"))
+        Chart(bolusStats) { stat in
+            // Total Bolus Bar
+            BarMark(
+                x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
+                y: .value("Amount", stat.manualBolus)
+            )
+            .foregroundStyle(by: .value("Type", "Manual"))
+            .position(by: .value("Type", "Manual"))
 
-                // SMB (Middle)
-                BarMark(
-                    x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
-                    y: .value("Amount", stat.smb)
-                )
-                .foregroundStyle(by: .value("Type", "SMB"))
+            // Carb Bolus Bar
+            BarMark(
+                x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
+                y: .value("Amount", stat.smb)
+            )
+            .foregroundStyle(by: .value("Type", "SMB"))
+            .position(by: .value("Type", "SMB"))
 
-                // External (Top)
-                BarMark(
-                    x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
-                    y: .value("Amount", stat.external)
-                )
-                .foregroundStyle(by: .value("Type", "External"))
-            }
+            // Correction Bolus Bar
+            BarMark(
+                x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
+                y: .value("Amount", stat.external)
+            )
+            .foregroundStyle(by: .value("Type", "External"))
+            .position(by: .value("Type", "External"))
 
             if let selectedDate,
                let selectedBolus = getBolusForDate(selectedDate)
@@ -238,7 +269,7 @@ struct BolusStatsView: View {
 
                     switch selectedDuration {
                     case .Day:
-                        if hour % 6 == 0 { // Show only every 6 hours (0, 6, 12, 18)
+                        if hour % 6 == 0 { // Show only every 6 hours
                             AxisValueLabel(format: dateFormat, centered: true)
                             AxisGridLine()
                         }
@@ -248,6 +279,7 @@ struct BolusStatsView: View {
                             AxisGridLine()
                         }
                     case .Total:
+                        // Only show January, April, July, October
                         if day == 1 && Calendar.current.component(.month, from: date) % 3 == 1 {
                             AxisValueLabel(format: dateFormat, centered: true)
                             AxisGridLine()
@@ -267,7 +299,9 @@ struct BolusStatsView: View {
                 matching: selectedDuration == .Day ?
                     DateComponents(minute: 0) : // Align to next hour for Day view
                     DateComponents(hour: 0), // Align to start of day for other views
-                majorAlignment: .matching(alignmentComponents)
+                majorAlignment: .matching(
+                    alignmentComponents
+                )
             )
         )
         .chartXVisibleDomain(length: visibleDomainLength)