Bläddra i källkod

Refactoring of Meal and Bolus distribution chart

polscm32 aka Marvout 1 år sedan
förälder
incheckning
827906309c

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

@@ -15,72 +15,53 @@ struct BolusStats: Identifiable {
 }
 
 extension Stat.StateModel {
-    /// Updates the bolus statistics for the currently selected time period
-    func updateBolusStats() {
+    func setupBolusStats() {
         Task {
-//            let stats = await fetchBolusStats(days: requestedDaysTDD, endDate: requestedEndDayTDD)
-//            await MainActor.run {
-//                self.bolusStats = stats
-//            }
+            let stats = await fetchBolusStats()
+            await MainActor.run {
+                self.bolusStats = stats
+            }
         }
     }
 
     /// Fetches and processes bolus statistics for a specific date range
-    /// - Parameters:
-    ///   - days: Number of days to fetch
-    ///   - endDate: The end date of the range
     /// - Returns: Array of BolusStats containing daily bolus statistics
-    func fetchBolusStats(days: Int, endDate: Date) async -> [BolusStats] {
+    func fetchBolusStats() async -> [BolusStats] {
         let calendar = Calendar.current
-        let endDate = calendar.startOfDay(for: endDate)
-        let startDate = calendar.date(byAdding: .day, value: -(days - 1), to: endDate)!
 
         // Fetch bolus records from Core Data
         let results = await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: BolusStored.self,
             onContext: bolusTaskContext,
-            predicate: NSPredicate(
-                format: "pumpEvent.timestamp >= %@ AND pumpEvent.timestamp < %@",
-                startDate as NSDate,
-                calendar.date(byAdding: .day, value: 1, to: endDate)! as NSDate
-            ),
+            predicate: NSPredicate.pumpHistoryForStats,
             key: "pumpEvent.timestamp",
-            ascending: false,
+            ascending: true,
             batchSize: 100
         )
 
         return await bolusTaskContext.perform {
             guard let fetchedResults = results as? [BolusStored] else { return [] }
 
-            // Group entries by day
-            let groupedEntries = Dictionary(grouping: fetchedResults) { entry in
-                calendar.startOfDay(for: entry.pumpEvent?.timestamp ?? Date())
-            }
-
-            // Create array of all dates in the range
-            var dates: [Date] = []
-            var currentDate = startDate
-            while currentDate <= endDate {
-                dates.append(calendar.startOfDay(for: currentDate))
-                currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)!
+            // Group boluses by day
+            let groupedByDay = Dictionary(grouping: fetchedResults) { bolus -> Date in
+                guard let timestamp = bolus.pumpEvent?.timestamp else { return Date() }
+                return calendar.startOfDay(for: timestamp)
             }
 
-            // Calculate statistics for each day
-            return dates.map { date in
-                let dayEntries = groupedEntries[date, default: []]
-
+            // Calculate daily totals
+            return groupedByDay.map { date, boluses -> BolusStats in
                 // Calculate total manual boluses (excluding SMB and external)
-                let manualBolus = dayEntries
+                let manualBolus = boluses
                     .filter { !($0.isExternal || $0.isSMB) }
                     .reduce(0.0) { $0 + (($1.amount as? Decimal) ?? 0).doubleValue }
 
                 // Calculate total SMB
-                let smb = dayEntries
+                let smb = boluses
                     .filter { $0.isSMB }
                     .reduce(0.0) { $0 + (($1.amount as? Decimal) ?? 0).doubleValue }
 
                 // Calculate total external boluses
-                let external = dayEntries
+                let external = boluses
                     .filter { $0.isExternal }
                     .reduce(0.0) { $0 + (($1.amount as? Decimal) ?? 0).doubleValue }
 
@@ -90,8 +71,27 @@ extension Stat.StateModel {
                     smb: smb,
                     external: external
                 )
-            }.sorted { $0.date < $1.date }
+            }
+        }
+    }
+
+    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
         }
+
+        guard !visibleStats.isEmpty else { return (0, 0, 0) }
+
+        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 }
+
+        return (
+            manualSum / count,
+            smbSum / count,
+            externalSum / count
+        )
     }
 }
 

+ 35 - 42
FreeAPS/Sources/Modules/Stat/StatStateModel+Setup/MealStatsSetup.swift

@@ -17,9 +17,9 @@ struct MealStats: Identifiable {
 extension Stat.StateModel {
     /// Initiates the process of fetching and processing meal statistics
     /// - Parameter duration: The time period to fetch records for
-    func setupMealStats(for duration: Duration) {
+    func setupMealStats() {
         Task {
-            let stats = await fetchMealStats(for: duration)
+            let stats = await fetchMealStats()
             await MainActor.run {
                 self.mealStats = stats
             }
@@ -29,34 +29,14 @@ extension Stat.StateModel {
     /// Fetches and processes meal statistics for a specific duration
     /// - Parameter duration: The time period to fetch records for (Today, 24h, 7 Days, 30 Days, or All)
     /// - Returns: Array of MealStats containing daily meal statistics, sorted by date
-    private func fetchMealStats(for duration: Duration) async -> [MealStats] {
-        let now = Date()
-        let calendar = Calendar.current
-
-        // Determine start date based on selected duration
-        // For Today and 24h, we show 3 days of data for better context
-        // For other durations, we fetch the respective time period
-        let startDate: Date
-        switch duration {
-        case .Today:
-            startDate = calendar.date(byAdding: .day, value: -2, to: calendar.startOfDay(for: now))!
-        case .Day:
-            startDate = calendar.date(byAdding: .day, value: -2, to: calendar.startOfDay(for: now))!
-        case .Week:
-            startDate = calendar.date(byAdding: .day, value: -7, to: calendar.startOfDay(for: now))!
-        case .Month:
-            startDate = calendar.date(byAdding: .month, value: -1, to: calendar.startOfDay(for: now))!
-        case .Total:
-            startDate = calendar.date(byAdding: .month, value: -3, to: calendar.startOfDay(for: now))!
-        }
-
+    private func fetchMealStats() async -> [MealStats] {
         // Fetch CarbEntryStored entries from Core Data
         let results = await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             onContext: mealTaskContext,
-            predicate: NSPredicate(format: "date >= %@", startDate as NSDate),
+            predicate: NSPredicate.carbsForStats,
             key: "date",
-            ascending: false,
+            ascending: true,
             batchSize: 100
         )
 
@@ -64,33 +44,24 @@ extension Stat.StateModel {
             // Safely unwrap the fetched results, return empty array if nil
             guard let fetchedResults = results as? [CarbEntryStored] else { return [] }
 
+            let calendar = Calendar.current
+
             // Group entries by day using calendar's startOfDay
-            // This ensures all entries within the same day are grouped together
-            // regardless of their specific time
             let groupedEntries = Dictionary(grouping: fetchedResults) { entry in
                 calendar.startOfDay(for: entry.date ?? Date())
             }
 
-            // Create array of all dates in the range
-            // This ensures we have entries for every day in the range,
-            // even if there are no meal entries for some days
-            var dates: [Date] = []
-            var currentDate = startDate
-            while currentDate <= now {
-                dates.append(calendar.startOfDay(for: currentDate))
-                currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)!
-            }
+            // Get all unique dates from the entries - they'll already be sorted
+            let dates = groupedEntries.keys.sorted()
 
             // Calculate statistics for each day
-            // For days without entries, all values will be 0
             return dates.map { date in
                 let entries = groupedEntries[date, default: []]
 
                 // Sum up macronutrients for the day
-                // Each reduce operation calculates the total for one macronutrient
-                let carbsTotal = entries.reduce(0.0) { $0 + $1.carbs } // Total carbs in grams
-                let fatTotal = entries.reduce(0.0) { $0 + $1.fat } // Total fat in grams
-                let proteinTotal = entries.reduce(0.0) { $0 + $1.protein } // Total protein in grams
+                let carbsTotal = entries.reduce(0.0) { $0 + $1.carbs }
+                let fatTotal = entries.reduce(0.0) { $0 + $1.fat }
+                let proteinTotal = entries.reduce(0.0) { $0 + $1.protein }
 
                 return MealStats(
                     date: date,
@@ -98,7 +69,29 @@ extension Stat.StateModel {
                     fat: fatTotal,
                     protein: proteinTotal
                 )
-            }.sorted { $0.date < $1.date } // Sort results by date in ascending order
+            }
         }
     }
+
+    func calculateAverageMealStats(
+        from startDate: Date,
+        to endDate: Date
+    ) async -> (carbs: Double, fat: Double, protein: Double) {
+        let filteredStats = self.mealStats.filter { stat in
+            stat.date >= startDate && stat.date <= endDate
+        }
+
+        guard !filteredStats.isEmpty else { return (0, 0, 0) }
+
+        let totalCarbs = filteredStats.reduce(0.0) { $0 + $1.carbs }
+        let totalFat = filteredStats.reduce(0.0) { $0 + $1.fat }
+        let totalProtein = filteredStats.reduce(0.0) { $0 + $1.protein }
+        let count = Double(filteredStats.count)
+
+        return (
+            carbs: totalCarbs / count,
+            fat: totalFat / count,
+            protein: totalProtein / count
+        )
+    }
 }

+ 69 - 2
FreeAPS/Sources/Modules/Stat/StatStateModel+Setup/TDDSetup.swift

@@ -2,13 +2,22 @@ import CoreData
 import Foundation
 
 extension Stat.StateModel {
+    func setupTDDs() {
+        Task {
+            let tddStats = await fetchAndMapDeterminations()
+            await MainActor.run {
+                self.tddStats = tddStats
+            }
+        }
+    }
+
     func fetchAndMapDeterminations() async -> [TDD] {
         let results = await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
             onContext: determinationFetchContext,
             predicate: NSPredicate.determinationsForStats,
             key: "deliverAt",
-            ascending: false,
+            ascending: true,
             propertiesToFetch: ["objectID", "timestamp", "deliverAt", "totalDailyDose"]
         )
 
@@ -37,7 +46,65 @@ extension Stat.StateModel {
                     timestamp: date
                 )
             }
-            .sorted { ($0.timestamp ?? Date()) > ($1.timestamp ?? Date()) }
+        }
+    }
+
+    var averageTDD: Decimal {
+        let calendar = Calendar.current
+        let now = Date()
+
+        // Filter TDDs based on selected time frame
+        let filteredTDDs: [TDD] = tddStats.filter { tdd in
+            guard let timestamp = tdd.timestamp else { return false }
+
+            switch selectedDurationForInsulinStats {
+            case .Day:
+                // Last 3 days
+                let threeDaysAgo = calendar.date(byAdding: .day, value: -3, to: now)!
+                return timestamp >= threeDaysAgo
+            case .Week:
+                // Last week
+                let weekAgo = calendar.date(byAdding: .weekOfYear, value: -1, to: now)!
+                return timestamp >= weekAgo
+            case .Month:
+                // Last month
+                let monthAgo = calendar.date(byAdding: .month, value: -1, to: now)!
+                return timestamp >= monthAgo
+            case .Total:
+                // Last 3 months
+                let threeMonthsAgo = calendar.date(byAdding: .month, value: -3, to: now)!
+                return timestamp >= threeMonthsAgo
+            }
+        }
+
+        let sum = filteredTDDs.reduce(Decimal.zero) { $0 + ($1.totalDailyDose ?? 0) }
+        return filteredTDDs.isEmpty ? 0 : sum / Decimal(filteredTDDs.count)
+    }
+
+    func calculateAverageTDD(from startDate: Date, to endDate: Date) async -> Decimal {
+        let filteredTDDs = tddStats.filter { tdd in
+            guard let timestamp = tdd.timestamp else { return false }
+            return timestamp >= startDate && timestamp <= endDate
+        }
+
+        let sum = filteredTDDs.reduce(Decimal.zero) { $0 + ($1.totalDailyDose ?? 0) }
+        return filteredTDDs.isEmpty ? 0 : sum / Decimal(filteredTDDs.count)
+    }
+
+    func calculateMedianTDD(from startDate: Date, to endDate: Date) async -> Decimal {
+        let filteredTDDs = tddStats.filter { tdd in
+            guard let timestamp = tdd.timestamp else { return false }
+            return timestamp >= startDate && timestamp <= endDate
+        }
+
+        let sortedDoses = filteredTDDs.compactMap(\.totalDailyDose).sorted()
+        guard !sortedDoses.isEmpty else { return 0 }
+
+        let middle = sortedDoses.count / 2
+        if sortedDoses.count % 2 == 0 {
+            return (sortedDoses[middle - 1] + sortedDoses[middle]) / 2
+        } else {
+            return sortedDoses[middle]
         }
     }
 }

+ 15 - 55
FreeAPS/Sources/Modules/Stat/StatStateModel.swift

@@ -17,6 +17,7 @@ extension Stat {
         var groupedLoopStats: [LoopStatsByPeriod] = []
         var mealStats: [MealStats] = []
         var tddStats: [TDD] = []
+        var bolusStats: [BolusStats] = []
         var selectedDurationForGlucoseStats: Duration = .Today {
             didSet {
                 setupGlucoseArray(for: selectedDurationForGlucoseStats)
@@ -24,6 +25,7 @@ extension Stat {
         }
 
         var selectedDurationForInsulinStats: StatsTimeInterval = .Day
+        var selectedDurationForMealStats: StatsTimeInterval = .Day
 
         var selectedDurationForLoopStats: Duration = .Today {
             didSet {
@@ -31,12 +33,6 @@ extension Stat {
             }
         }
 
-        var selectedDurationForMealStats: Duration = .Today {
-            didSet {
-                setupMealStats(for: selectedDurationForMealStats)
-            }
-        }
-
         let context = CoreDataStack.shared.newTaskContext()
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext
         let determinationFetchContext = CoreDataStack.shared.newTaskContext()
@@ -66,14 +62,12 @@ extension Stat {
         var hourlyStats: [HourlyStats] = []
         var glucoseRangeStats: [GlucoseRangeStats] = []
 
-        var bolusStats: [BolusStats] = []
-
         override func subscribe() {
             setupGlucoseArray(for: .Today)
             setupTDDs()
+            setupBolusStats()
             setupLoopStatRecords()
-            setupMealStats(for: selectedDurationForMealStats)
-            updateBolusStats()
+            setupMealStats()
             highLimit = settingsManager.settings.high
             lowLimit = settingsManager.settings.low
             units = settingsManager.settings.units
@@ -93,15 +87,6 @@ extension Stat {
             }
         }
 
-        func setupTDDs() {
-            Task {
-                let tddStats = await fetchAndMapDeterminations()
-                await MainActor.run {
-                    self.tddStats = tddStats
-                }
-            }
-        }
-
         private func fetchGlucose(for duration: Duration) async -> [NSManagedObjectID] {
             let predicate: NSPredicate
 
@@ -147,47 +132,22 @@ extension Stat {
                 )
             }
         }
+    }
 
-        var averageTDD: Decimal {
-            let calendar = Calendar.current
-            let now = Date()
-
-            // Filter TDDs based on selected time frame
-            let filteredTDDs: [TDD] = tddStats.filter { tdd in
-                guard let timestamp = tdd.timestamp else { return false }
-
-                switch selectedDurationForInsulinStats {
-                case .Day:
-                    // Last 3 days
-                    let threeDaysAgo = calendar.date(byAdding: .day, value: -3, to: now)!
-                    return timestamp >= threeDaysAgo
-                case .Week:
-                    // Last week
-                    let weekAgo = calendar.date(byAdding: .weekOfYear, value: -1, to: now)!
-                    return timestamp >= weekAgo
-                case .Month:
-                    // Last month
-                    let monthAgo = calendar.date(byAdding: .month, value: -1, to: now)!
-                    return timestamp >= monthAgo
-                case .Total:
-                    // Last 3 months
-                    let threeMonthsAgo = calendar.date(byAdding: .month, value: -3, to: now)!
-                    return timestamp >= threeMonthsAgo
-                }
-            }
+    @Observable final class UpdateTimer {
+        private var workItem: DispatchWorkItem?
 
-            let sum = filteredTDDs.reduce(Decimal.zero) { $0 + ($1.totalDailyDose ?? 0) }
-            return filteredTDDs.isEmpty ? 0 : sum / Decimal(filteredTDDs.count)
-        }
+        func scheduleUpdate(action: @escaping () -> Void) {
+            workItem?.cancel()
 
-        func calculateAverageTDD(from startDate: Date, to endDate: Date) -> Decimal {
-            let filteredTDDs = tddStats.filter { tdd in
-                guard let timestamp = tdd.timestamp else { return false }
-                return timestamp >= startDate && timestamp <= endDate
+            let newWorkItem = DispatchWorkItem {
+                Task { @MainActor in
+                    action()
+                }
             }
+            workItem = newWorkItem
 
-            let sum = filteredTDDs.reduce(Decimal.zero) { $0 + ($1.totalDailyDose ?? 0) }
-            return filteredTDDs.isEmpty ? 0 : sum / Decimal(filteredTDDs.count)
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: newWorkItem)
         }
     }
 }

+ 46 - 34
FreeAPS/Sources/Modules/Stat/View/StatRootView.swift

@@ -158,39 +158,48 @@ extension Stat {
             }
             .pickerStyle(.segmented)
 
-            switch selectedInsulinChartType {
-            case .totalDailyDose:
-                if state.tddStats.isEmpty {
-                    ContentUnavailableView(
-                        "No TDD Data",
-                        systemImage: "chart.bar.xaxis",
-                        description: Text("Total Daily Doses will appear here once data is available.")
-                    )
-                } else {
-                    TDDChartView(
-                        selectedDuration: state.selectedDurationForInsulinStats,
-                        tddStats: state.tddStats,
-                        calculateAverage: { start, end in
-                            state.calculateAverageTDD(from: start, to: end)
-                        }
-                    )
-                }
+            StatCard {
+                switch selectedInsulinChartType {
+                case .totalDailyDose:
+                    if state.tddStats.isEmpty {
+                        ContentUnavailableView(
+                            "No TDD Data",
+                            systemImage: "chart.bar.xaxis",
+                            description: Text("Total Daily Doses will appear here once data is available.")
+                        )
+                    } else {
+                        TDDChartView(
+                            selectedDuration: $state.selectedDurationForInsulinStats,
+                            tddStats: state.tddStats,
+                            calculateAverage: { start, end in
+                                await state.calculateAverageTDD(from: start, to: end)
+                            },
+                            calculateMedian: { start, end in
+                                await state.calculateMedianTDD(from: start, to: end)
+                            }
+                        )
+                    }
 
-            case .bolusDistribution:
-                var hasBolusData: Bool {
-                    state.bolusStats.contains { $0.manualBolus > 0 || $0.smb > 0 || $0.external > 0 }
-                }
+                case .bolusDistribution:
+                    var hasBolusData: Bool {
+                        state.bolusStats.contains { $0.manualBolus > 0 || $0.smb > 0 || $0.external > 0 }
+                    }
 
-                if state.bolusStats.isEmpty || !hasBolusData {
-                    ContentUnavailableView(
-                        "No Bolus Data",
-                        systemImage: "cross.vial",
-                        description: Text("Bolus statistics will appear here once data is available.")
-                    )
-                } else {
-                    BolusStatsView(
-                        bolusStats: state.bolusStats
-                    )
+                    if state.bolusStats.isEmpty || !hasBolusData {
+                        ContentUnavailableView(
+                            "No Bolus Data",
+                            systemImage: "cross.vial",
+                            description: Text("Bolus statistics will appear here once data is available.")
+                        )
+                    } else {
+                        BolusStatsView(
+                            selectedDuration: $state.selectedDurationForInsulinStats,
+                            bolusStats: state.bolusStats,
+                            calculateAverages: { start, end in
+                                await state.calculateAverageBolus(from: start, to: end)
+                            }
+                        )
+                    }
                 }
             }
         }
@@ -337,8 +346,8 @@ extension Stat {
             }.padding(.horizontal)
 
             Picker("Duration", selection: $state.selectedDurationForMealStats) {
-                ForEach(StateModel.Duration.allCases, id: \.self) { duration in
-                    Text(duration.rawValue)
+                ForEach(StateModel.StatsTimeInterval.allCases, id: \.self) { timeInterval in
+                    Text(timeInterval.rawValue)
                 }
             }
             .pickerStyle(.segmented)
@@ -358,8 +367,11 @@ extension Stat {
                         )
                     } else {
                         MealStatsView(
+                            selectedDuration: $state.selectedDurationForMealStats,
                             mealStats: state.mealStats,
-                            selectedDuration: state.selectedDurationForMealStats
+                            calculateAverages: { start, end in
+                                await state.calculateAverageMealStats(from: start, to: end)
+                            }
                         )
                     }
                 case .mealToHypoHyperDistribution:

+ 226 - 35
FreeAPS/Sources/Modules/Stat/View/ViewElements/BolusStatsView.swift

@@ -2,15 +2,79 @@ import Charts
 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)
+
+    @State private var scrollPosition = Date()
+    @State private var selectedDate: Date?
+    @State private var currentAverages: (manual: Double, smb: Double, external: Double) = (0, 0, 0)
+    @State private var updateTimer = Stat.UpdateTimer()
+
+    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
+        }
+    }
+
+    private var visibleDateRange: (start: Date, end: Date) {
+        let halfDomain = visibleDomainLength / 2
+        let start = scrollPosition.addingTimeInterval(-halfDomain)
+        let end = scrollPosition.addingTimeInterval(halfDomain)
+        return (start, end)
+    }
+
+    private var dateFormat: Date.FormatStyle {
+        switch selectedDuration {
+        case .Day:
+            return .dateTime.weekday(.abbreviated)
+        case .Week:
+            return .dateTime.weekday(.abbreviated)
+        case .Month:
+            return .dateTime.day()
+        case .Total:
+            return .dateTime.month(.abbreviated)
+        }
+    }
+
+    private var alignmentComponents: DateComponents {
+        switch selectedDuration {
+        case .Day:
+            return DateComponents(hour: 0) // Align to start of day
+        case .Week:
+            return DateComponents(weekday: 2) // 2 = Monday in Calendar
+        case .Month,
+             .Total:
+            return DateComponents(day: 1) // Align to first day of month
+        }
+    }
+
+    private func getBolusForDate(_ date: Date) -> BolusStats? {
+        bolusStats.first { stat in
+            Calendar.current.isDate(stat.date, inSameDayAs: date)
+        }
+    }
+
+    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
+            }
+        }
+    }
 
     var body: some View {
-        StatCard {
-            VStack(alignment: .leading, spacing: 8) {
-                Text("Bolus Distribution")
-                    .font(.headline)
+        VStack(alignment: .leading, spacing: 8) {
+            statsView
 
-                Chart(bolusStats) { stat in
+            Chart {
+                ForEach(bolusStats) { stat in
                     // External Bolus (Bottom)
                     BarMark(
                         x: .value("Date", stat.date, unit: .day),
@@ -32,52 +96,179 @@ struct BolusStatsView: View {
                     )
                     .foregroundStyle(by: .value("Type", "Manual"))
                 }
-                .chartForegroundStyleScale([
-                    "Manual": Color.teal,
-                    "SMB": Color.blue,
-                    "External": Color.purple
-                ])
-                .chartLegend(position: .bottom, alignment: .leading, spacing: 12)
-                .frame(height: 200)
-                .chartXAxis {
-                    bolusStatsChartXAxisMarks
+
+                if let selectedDate,
+                   let selectedBolus = getBolusForDate(selectedDate)
+                {
+                    RuleMark(
+                        x: .value("Selected Date", selectedDate)
+                    )
+                    .foregroundStyle(.secondary.opacity(0.3))
+                    .annotation(
+                        position: .top,
+                        spacing: 0,
+                        overflowResolution: .init(x: .fit, y: .disabled)
+                    ) {
+                        BolusSelectionPopover(date: selectedDate, bolus: selectedBolus)
+                    }
                 }
-                .chartYAxis {
-                    bolusStatsChartYAxisMarks
+            }
+            .chartForegroundStyleScale([
+                "Manual": Color.teal,
+                "SMB": Color.blue,
+                "External": Color.purple
+            ])
+            .chartLegend(position: .bottom, alignment: .leading, spacing: 12)
+            .chartYAxis {
+                AxisMarks(position: .trailing) { value in
+                    if let amount = value.as(Double.self) {
+                        AxisValueLabel {
+                            Text(amount.formatted(.number.precision(.fractionLength(1))) + " U")
+                        }
+                        AxisGridLine()
+                    }
                 }
             }
+            .chartXAxis {
+                AxisMarks(preset: .aligned, values: .stride(by: .day)) { value in
+                    if let date = value.as(Date.self) {
+                        let day = Calendar.current.component(.day, from: date)
 
-            List {
-                ForEach(bolusStats) { _ in
-                    Text("")
+                        switch selectedDuration {
+                        case .Month:
+                            if day % 5 == 0 { // Only show every 5th day
+                                AxisValueLabel(format: dateFormat)
+                                AxisGridLine()
+                            }
+                        case .Total:
+                            // Only show January, April, July, October
+                            if day == 1 && Calendar.current.component(.month, from: date) % 3 == 1 {
+                                AxisValueLabel(format: dateFormat)
+                                AxisGridLine()
+                            }
+                        default:
+                            AxisValueLabel(format: dateFormat)
+                            AxisGridLine()
+                        }
+                    }
                 }
             }
+            .chartXSelection(value: $selectedDate)
+            .chartScrollableAxes(.horizontal)
+            .chartScrollPosition(x: $scrollPosition)
+            .chartScrollTargetBehavior(
+                .valueAligned(
+                    matching: alignmentComponents,
+                    majorAlignment: .matching(alignmentComponents)
+                )
+            )
+            .chartXVisibleDomain(length: visibleDomainLength)
+            .frame(height: 200)
+        }
+
+        .onAppear {
+            updateAverages()
+        }
+        .onChange(of: scrollPosition) {
+            updateTimer.scheduleUpdate {
+                updateAverages()
+            }
+        }
+        .onChange(of: selectedDuration) {
+            updateAverages()
+            scrollPosition = Date()
         }
     }
 
-    private var bolusStatsChartXAxisMarks: some AxisContent {
-        AxisMarks { value in
-            if let date = value.as(Date.self) {
-                AxisValueLabel {
-//                    if selectedDays < 8 {
-//                        Text(date, format: .dateTime.weekday(.abbreviated))
-//                    } else {
-//                        Text(date, format: .dateTime.day().month(.defaultDigits))
-//                    }
+    private var statsView: some View {
+        HStack {
+            Grid(alignment: .leading) {
+                GridRow {
+                    Text("Manual:")
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                    Text(currentAverages.manual.formatted(.number.precision(.fractionLength(1))))
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                        .gridColumnAlignment(.trailing)
+                    Text("U")
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                }
+                GridRow {
+                    Text("SMB:")
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                    Text(currentAverages.smb.formatted(.number.precision(.fractionLength(1))))
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                        .gridColumnAlignment(.trailing)
+                    Text("U")
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                }
+                GridRow {
+                    Text("External:")
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                    Text(currentAverages.external.formatted(.number.precision(.fractionLength(1))))
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                        .gridColumnAlignment(.trailing)
+                    Text("U")
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
                 }
-                AxisGridLine()
             }
+
+            Spacer()
+
+            Text(
+                "\(visibleDateRange.start.formatted(.dateTime.month().day())) - \(visibleDateRange.end.formatted(.dateTime.month().day()))"
+            )
+            .font(.subheadline)
+            .foregroundStyle(.secondary)
         }
     }
+}
+
+private struct BolusSelectionPopover: View {
+    let date: Date
+    let bolus: BolusStats
 
-    private var bolusStatsChartYAxisMarks: some AxisContent {
-        AxisMarks(position: .leading) { value in
-            if let amount = value.as(Double.self) {
-                AxisValueLabel {
-                    Text(amount.formatted(.number.precision(.fractionLength(1))) + " U")
+    var body: some View {
+        VStack(alignment: .leading, spacing: 4) {
+            Text(date.formatted(.dateTime.month().day()))
+                .font(.caption)
+                .foregroundStyle(.secondary)
+
+            Grid(alignment: .leading) {
+                GridRow {
+                    Text("Manual:")
+                    Text(bolus.manualBolus.formatted(.number.precision(.fractionLength(1))))
+                        .gridColumnAlignment(.trailing)
+                    Text("U")
+                }
+                GridRow {
+                    Text("SMB:")
+                    Text(bolus.smb.formatted(.number.precision(.fractionLength(1))))
+                        .gridColumnAlignment(.trailing)
+                    Text("U")
+                }
+                GridRow {
+                    Text("External:")
+                    Text(bolus.external.formatted(.number.precision(.fractionLength(1))))
+                        .gridColumnAlignment(.trailing)
+                    Text("U")
                 }
-                AxisGridLine()
             }
+            .font(.caption)
         }
+        .padding(8)
+        .background(
+            RoundedRectangle(cornerRadius: 8)
+                .fill(Color(.systemBackground))
+                .shadow(radius: 2)
+        )
     }
 }

+ 244 - 56
FreeAPS/Sources/Modules/Stat/View/ViewElements/MealStatsView.swift

@@ -2,85 +2,273 @@ import Charts
 import SwiftUI
 
 struct MealStatsView: View {
+    @Binding var selectedDuration: Stat.StateModel.StatsTimeInterval
     let mealStats: [MealStats]
-    let selectedDuration: Stat.StateModel.Duration
+    let calculateAverages: @Sendable(Date, Date) async -> (carbs: Double, fat: Double, protein: Double)
+
+    @State private var scrollPosition = Date()
+    @State private var selectedDate: Date?
+    @State private var currentAverages: (carbs: Double, fat: Double, protein: Double) = (0, 0, 0)
+    @State private var updateTimer = Stat.UpdateTimer()
+
+    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
+        }
+    }
+
+    private var visibleDateRange: (start: Date, end: Date) {
+        let halfDomain = visibleDomainLength / 2
+        let start = scrollPosition.addingTimeInterval(-halfDomain)
+        let end = scrollPosition.addingTimeInterval(halfDomain)
+        return (start, end)
+    }
+
+    private var dateFormat: Date.FormatStyle {
+        switch selectedDuration {
+        case .Day:
+            return .dateTime.weekday(.abbreviated)
+        case .Week:
+            return .dateTime.weekday(.abbreviated)
+        case .Month:
+            return .dateTime.day()
+        case .Total:
+            return .dateTime.month(.abbreviated)
+        }
+    }
+
+    private var alignmentComponents: DateComponents {
+        switch selectedDuration {
+        case .Day:
+            return DateComponents(hour: 0) // Align to start of day
+        case .Week:
+            return DateComponents(weekday: 2) // 2 = Monday in Calendar
+        case .Month,
+             .Total:
+            return DateComponents(day: 1) // Align to first day of month
+        }
+    }
+
+    private func getMealForDate(_ date: Date) -> MealStats? {
+        mealStats.first { stat in
+            Calendar.current.isDate(stat.date, inSameDayAs: date)
+        }
+    }
+
+    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
+            }
+        }
+    }
 
     var body: some View {
         VStack(alignment: .leading, spacing: 8) {
-            Text("Macronutrients")
-                .font(.headline)
-
-            Chart(mealStats) { stat in
-                // Carbs Bar
-                BarMark(
-                    x: .value("Date", stat.date, unit: .day),
-                    y: .value("Amount", stat.carbs),
-                    width: .ratio(0.6)
-                )
-                .foregroundStyle(Color.orange)
-                .position(by: .value("Nutrient", "Carbs"))
-
-                // Fat Bar
-                BarMark(
-                    x: .value("Date", stat.date, unit: .day),
-                    y: .value("Amount", stat.fat),
-                    width: .ratio(0.6)
-                )
-                .foregroundStyle(Color.yellow)
-                .position(by: .value("Nutrient", "Fat"))
-
-                // Protein Bar
-                BarMark(
-                    x: .value("Date", stat.date, unit: .day),
-                    y: .value("Amount", stat.protein),
-                    width: .ratio(0.6)
-                )
-                .foregroundStyle(Color.green)
-                .position(by: .value("Nutrient", "Protein"))
+            statsView
+
+            Chart {
+                ForEach(mealStats) { stat in
+                    // Carbs (Bottom)
+                    BarMark(
+                        x: .value("Date", stat.date, unit: .day),
+                        y: .value("Amount", stat.carbs)
+                    )
+                    .foregroundStyle(by: .value("Type", "Carbs"))
+
+                    // Fat (Middle)
+                    BarMark(
+                        x: .value("Date", stat.date, unit: .day),
+                        y: .value("Amount", stat.fat)
+                    )
+                    .foregroundStyle(by: .value("Type", "Fat"))
+
+                    // Protein (Top)
+                    BarMark(
+                        x: .value("Date", stat.date, unit: .day),
+                        y: .value("Amount", stat.protein)
+                    )
+                    .foregroundStyle(by: .value("Type", "Protein"))
+                }
+
+                if let selectedDate,
+                   let selectedMeal = getMealForDate(selectedDate)
+                {
+                    RuleMark(
+                        x: .value("Selected Date", selectedDate)
+                    )
+                    .foregroundStyle(.secondary.opacity(0.3))
+                    .annotation(
+                        position: .top,
+                        spacing: 0,
+                        overflowResolution: .init(x: .fit, y: .disabled)
+                    ) {
+                        MealSelectionPopover(date: selectedDate, meal: selectedMeal)
+                    }
+                }
             }
             .chartForegroundStyleScale([
                 "Carbs": Color.orange,
-                "Fat": Color.yellow,
+                "Fat": Color.blue,
                 "Protein": Color.green
             ])
             .chartLegend(position: .bottom, alignment: .leading, spacing: 12)
-            .frame(height: 200)
+            .chartYAxis {
+                AxisMarks(position: .trailing) { value in
+                    if let amount = value.as(Double.self) {
+                        AxisValueLabel {
+                            Text(amount.formatted(.number.precision(.fractionLength(1))) + " g")
+                        }
+                        AxisGridLine()
+                    }
+                }
+            }
             .chartXAxis {
-                mealChartXAxisMarks
+                AxisMarks(preset: .aligned, values: .stride(by: .day)) { value in
+                    if let date = value.as(Date.self) {
+                        let day = Calendar.current.component(.day, from: date)
+
+                        switch selectedDuration {
+                        case .Month:
+                            if day % 5 == 0 { // Only show every 5th day
+                                AxisValueLabel(format: dateFormat)
+                                AxisGridLine()
+                            }
+                        case .Total:
+                            // Only show January, April, July, October
+                            if day == 1 && Calendar.current.component(.month, from: date) % 3 == 1 {
+                                AxisValueLabel(format: dateFormat)
+                                AxisGridLine()
+                            }
+                        default:
+                            AxisValueLabel(format: dateFormat)
+                            AxisGridLine()
+                        }
+                    }
+                }
             }
-            .chartYAxis {
-                mealChartYAxisMarks
+            .chartXSelection(value: $selectedDate)
+            .chartScrollableAxes(.horizontal)
+            .chartScrollPosition(x: $scrollPosition)
+            .chartScrollTargetBehavior(
+                .valueAligned(
+                    matching: alignmentComponents,
+                    majorAlignment: .matching(alignmentComponents)
+                )
+            )
+            .chartXVisibleDomain(length: visibleDomainLength)
+            .frame(height: 200)
+        }
+
+        .onAppear {
+            updateAverages()
+        }
+        .onChange(of: scrollPosition) {
+            updateTimer.scheduleUpdate {
+                updateAverages()
             }
         }
+        .onChange(of: selectedDuration) {
+            updateAverages()
+            scrollPosition = Date()
+        }
     }
 
-    private var mealChartXAxisMarks: some AxisContent {
-        AxisMarks { value in
-            if let date = value.as(Date.self) {
-                AxisValueLabel {
-                    switch selectedDuration {
-                    case .Day,
-                         .Today,
-                         .Week:
-                        Text(date, format: .dateTime.weekday(.abbreviated))
-                    case .Month,
-                         .Total:
-                        Text(date, format: .dateTime.day().month(.defaultDigits))
-                    }
+    private var statsView: some View {
+        HStack {
+            Grid(alignment: .leading) {
+                GridRow {
+                    Text("Carbs:")
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                    Text(currentAverages.carbs.formatted(.number.precision(.fractionLength(1))))
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                        .gridColumnAlignment(.trailing)
+                    Text("g")
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                }
+                GridRow {
+                    Text("Fat:")
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                    Text(currentAverages.fat.formatted(.number.precision(.fractionLength(1))))
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                        .gridColumnAlignment(.trailing)
+                    Text("g")
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                }
+                GridRow {
+                    Text("Protein:")
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                    Text(currentAverages.protein.formatted(.number.precision(.fractionLength(1))))
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                        .gridColumnAlignment(.trailing)
+                    Text("g")
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
                 }
-                AxisGridLine()
             }
+
+            Spacer()
+
+            Text(
+                "\(visibleDateRange.start.formatted(.dateTime.month().day())) - \(visibleDateRange.end.formatted(.dateTime.month().day()))"
+            )
+            .font(.subheadline)
+            .foregroundStyle(.secondary)
         }
     }
+}
 
-    private var mealChartYAxisMarks: some AxisContent {
-        AxisMarks(position: .leading) { value in
-            if let amount = value.as(Double.self) {
-                AxisValueLabel {
-                    Text("\(Int(amount))g")
+private struct MealSelectionPopover: View {
+    let date: Date
+    let meal: MealStats
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 4) {
+            Text(date.formatted(.dateTime.month().day()))
+                .font(.caption)
+                .foregroundStyle(.secondary)
+
+            Grid(alignment: .leading) {
+                GridRow {
+                    Text("Carbs:")
+                    Text(meal.carbs.formatted(.number.precision(.fractionLength(1))))
+                        .gridColumnAlignment(.trailing)
+                    Text("g")
+                }
+                GridRow {
+                    Text("Fat:")
+                    Text(meal.fat.formatted(.number.precision(.fractionLength(1))))
+                        .gridColumnAlignment(.trailing)
+                    Text("g")
+                }
+                GridRow {
+                    Text("Protein:")
+                    Text(meal.protein.formatted(.number.precision(.fractionLength(1))))
+                        .gridColumnAlignment(.trailing)
+                    Text("g")
                 }
-                AxisGridLine()
             }
+            .font(.caption)
         }
+        .padding(8)
+        .background(
+            RoundedRectangle(cornerRadius: 8)
+                .fill(Color(.systemBackground))
+                .shadow(radius: 2)
+        )
     }
 }

+ 73 - 36
FreeAPS/Sources/Modules/Stat/View/ViewElements/TDDChart.swift

@@ -2,14 +2,18 @@ import Charts
 import SwiftUI
 
 struct TDDChartView: View {
-    let selectedDuration: Stat.StateModel.StatsTimeInterval
+    @Binding var selectedDuration: Stat.StateModel.StatsTimeInterval
     let tddStats: [TDD]
-    let calculateAverage: (Date, Date) -> Decimal
+    let calculateAverage: @Sendable(Date, Date) async -> Decimal
+    let calculateMedian: @Sendable(Date, Date) async -> Decimal
 
     @State private var scrollPosition = Date()
     @State private var currentAverageTDD: Decimal = 0
+    @State private var currentMedianTDD: Decimal = 0
     @State private var selectedDate: Date?
 
+    @State private var updateTimer = Stat.UpdateTimer()
+
     private var visibleDomainLength: TimeInterval {
         switch selectedDuration {
         case .Day: return 3 * 24 * 3600 // 3 days
@@ -28,10 +32,6 @@ struct TDDChartView: View {
         }
     }
 
-    private var strideInterval: Calendar.Component {
-        .day
-    }
-
     private var dateFormat: Date.FormatStyle {
         switch selectedDuration {
         case .Day:
@@ -48,13 +48,12 @@ struct TDDChartView: View {
     private var alignmentComponents: DateComponents {
         switch selectedDuration {
         case .Day:
-            return DateComponents(hour: 0)
+            return DateComponents(hour: 0) // Align to start of day
         case .Week:
-            return DateComponents(weekday: 1)
-        case .Month:
-            return DateComponents(day: 1)
-        case .Total:
-            return DateComponents(day: 1, hour: 0)
+            return DateComponents(weekday: 2) // 2 = Monday in Calendar
+        case .Month,
+             .Total:
+            return DateComponents(day: 1) // Align to first day of month
         }
     }
 
@@ -65,9 +64,17 @@ struct TDDChartView: View {
         return (start, end)
     }
 
-    private func updateAverage() {
-        let (start, end) = visibleDateRange
-        currentAverageTDD = calculateAverage(start, end)
+    private func updateStats() {
+        Task.detached(priority: .userInitiated) {
+            let dateRange = await MainActor.run { visibleDateRange }
+            let avgTDD = await calculateAverage(dateRange.start, dateRange.end)
+            let medTDD = await calculateMedian(dateRange.start, dateRange.end)
+
+            await MainActor.run {
+                currentAverageTDD = avgTDD
+                currentMedianTDD = medTDD
+            }
+        }
     }
 
     private func getTDDForDate(_ date: Date) -> TDD? {
@@ -79,11 +86,17 @@ struct TDDChartView: View {
 
     var body: some View {
         chartCard
+            .onAppear {
+                updateStats()
+            }
             .onChange(of: scrollPosition) {
-                updateAverage()
+                updateTimer.scheduleUpdate {
+                    updateStats()
+                }
             }
-            .onAppear {
-                updateAverage()
+            .onChange(of: selectedDuration) {
+                updateStats()
+                scrollPosition = Date()
             }
     }
 
@@ -91,27 +104,12 @@ struct TDDChartView: View {
 
     private var chartCard: some View {
         VStack(alignment: .leading, spacing: 8) {
-            VStack(alignment: .leading, spacing: 6) {
-                Text("Total Daily Doses")
-                    .font(.headline)
-
-                VStack(alignment: .leading, spacing: 4) {
-                    Text("Average: \(currentAverageTDD.formatted(.number.precision(.fractionLength(1)))) U")
-                        .font(.headline)
-                        .foregroundStyle(.secondary)
-
-                    Text(
-                        "\(visibleDateRange.start.formatted(.dateTime.month().day())) - \(visibleDateRange.end.formatted(.dateTime.month().day()))"
-                    )
-                    .font(.subheadline)
-                    .foregroundStyle(.secondary)
-                }
-            }
+            statsView
 
             Chart {
                 ForEach(tddStats) { entry in
                     BarMark(
-                        x: .value("Date", entry.timestamp ?? Date(), unit: strideInterval),
+                        x: .value("Date", entry.timestamp ?? Date(), unit: .day),
                         y: .value("Insulin", entry.totalDailyDose ?? 0)
                     )
                     .foregroundStyle(Color.insulin.gradient)
@@ -140,7 +138,7 @@ struct TDDChartView: View {
                 }
             }
             .chartXAxis {
-                AxisMarks(preset: .aligned, values: .stride(by: strideInterval)) { value in
+                AxisMarks(preset: .aligned, values: .stride(by: .day)) { value in
                     if let date = value.as(Date.self) {
                         let day = Calendar.current.component(.day, from: date)
 
@@ -177,6 +175,45 @@ struct TDDChartView: View {
         }
     }
 
+    private var statsView: some View {
+        HStack {
+            Grid(alignment: .leading) {
+                GridRow {
+                    Text("Average:")
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                    Text(currentAverageTDD.formatted(.number.precision(.fractionLength(1))))
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                        .gridColumnAlignment(.trailing)
+                    Text("U")
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                }
+                GridRow {
+                    Text("Median:")
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                    Text(currentMedianTDD.formatted(.number.precision(.fractionLength(1))))
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                        .gridColumnAlignment(.trailing)
+                    Text("U")
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+                }
+            }
+
+            Spacer()
+
+            Text(
+                "\(visibleDateRange.start.formatted(.dateTime.month().day())) - \(visibleDateRange.end.formatted(.dateTime.month().day()))"
+            )
+            .font(.subheadline)
+            .foregroundStyle(.secondary)
+        }
+    }
+
     private struct TDDSelectionPopover: View {
         let date: Date
         let tdd: TDD

+ 5 - 0
Model/Helper/CarbEntryStored+helper.swift

@@ -12,6 +12,11 @@ extension NSPredicate {
         return NSPredicate(format: "isFPU == false AND date >= %@ AND carbs > 0", date as NSDate)
     }
 
+    static var carbsForStats: NSPredicate {
+        let date = Date.threeMonthsAgo
+        return NSPredicate(format: "date >= %@", date as NSDate)
+    }
+
     static var carbsNotYetUploadedToNightscout: NSPredicate {
         let date = Date.oneDayAgo
         return NSPredicate(

+ 5 - 0
Model/Helper/PumpEvent+helper.swift

@@ -63,6 +63,11 @@ extension NSPredicate {
         return NSPredicate(format: "timestamp >= %@", date as NSDate)
     }
 
+    static var pumpHistoryForStats: NSPredicate {
+        let date = Date.threeMonthsAgo
+        return NSPredicate(format: "pumpEvent.timestamp >= %@", date as NSDate)
+    }
+
     static var recentPumpHistory: NSPredicate {
         let date = Date.twentyMinutesAgo
         return NSPredicate(format: "timestamp >= %@", date as NSDate)