Просмотр исходного кода

revert changes to glucose charts

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

+ 2 - 2
FreeAPS.xcodeproj/project.pbxproj

@@ -2529,11 +2529,11 @@
 			isa = PBXGroup;
 			children = (
 				BD249DA02D42FD1000412DEB /* TDDSetup.swift */,
-				BD249D9E2D42FD0200412DEB /* StackedChartSetup.swift */,
 				BD249D9C2D42FCF300412DEB /* MealStatsSetup.swift */,
-				BD249D9A2D42FCD800412DEB /* LoopChartSetup.swift */,
 				BD249D982D42FCCA00412DEB /* BolusStatsSetup.swift */,
 				BD249D962D42FCBD00412DEB /* AreaChartSetup.swift */,
+				BD249D9E2D42FD0200412DEB /* StackedChartSetup.swift */,
+				BD249D9A2D42FCD800412DEB /* LoopChartSetup.swift */,
 			);
 			path = "StatStateModel+Setup";
 			sourceTree = "<group>";

+ 87 - 56
FreeAPS/Sources/Modules/Stat/StatStateModel+Setup/AreaChartSetup.swift

@@ -10,13 +10,8 @@ import Foundation
 /// - The 25th and 75th percentiles form the interquartile range (IQR)
 /// - The 10th and 90th percentiles show the wider range of values
 ///
-/// The data is used to create area charts with:
-/// - A dark blue area for the interquartile range (25th-75th percentile)
-/// - A light blue area for the wider range (10th-90th percentile)
-/// - A solid blue line for the median
-///
-/// Example usage:
-/// ```swift
+/// Example usage in visualization:
+/// ```
 /// let stats = HourlyStats(
 ///     hour: 14,        // 2 PM
 ///     median: 120,     // Center line
@@ -45,69 +40,105 @@ public struct HourlyStats: Equatable {
 }
 
 extension Double {
-    /// Helper property to check if a number is even
     var isEven: Bool {
         truncatingRemainder(dividingBy: 2) == 0
     }
 }
 
 extension Stat.StateModel {
-    /// Calculates hourly statistics for grouped glucose values
-    /// - Parameter groupedValues: Dictionary with dates as keys and arrays of glucose readings as values
-    /// - Returns: Dictionary with dates as keys and arrays of hourly statistics as values
-    ///
-    /// This function processes glucose readings grouped by date to calculate hourly statistics
-    /// for each group. The statistics include median and various percentiles that show
-    /// the distribution of glucose values throughout the day.
-    func calculateStats(
-        for groupedValues: [Date: [GlucoseStored]]
-    ) -> [Date: [HourlyStats]] {
-        groupedValues.mapValues { values in
-            calculateHourlyStats(from: values.map(\.objectID))
-        }
-    }
-
-    /// Calculates detailed hourly statistics for a set of glucose readings
-    /// - Parameter ids: Array of NSManagedObjectIDs for glucose readings
-    /// - Returns: Array of HourlyStats containing percentile calculations for each hour
+    /// Calculates hourly statistical values (median, percentiles) from glucose readings.
+    /// The calculation runs asynchronously using the CoreData context.
     ///
-    /// The calculation process:
-    /// 1. Groups readings by hour of day (0-23)
+    /// The calculation works as follows:
+    /// 1. Group readings by hour of day (0-23)
     /// 2. For each hour:
-    ///    - Sorts glucose values
-    ///    - Calculates median (50th percentile)
-    ///    - Calculates 10th, 25th, 75th, and 90th percentiles
+    ///    - Sort glucose values
+    ///    - Calculate median (50th percentile)
+    ///    - Calculate 10th, 25th, 75th, and 90th percentiles
     ///
-    /// These statistics are used to show:
-    /// - The typical glucose range for each hour
-    /// - The variability of glucose values
-    /// - Patterns in glucose behavior throughout the day
-    func calculateHourlyStats(from ids: [NSManagedObjectID]) -> [HourlyStats] {
+    /// Example:
+    /// For readings at 6:00 AM across multiple days:
+    /// ```
+    /// Readings: [80, 100, 120, 140, 160, 180, 200]
+    /// Results:
+    /// - 10th percentile: 84 (lower whisker)
+    /// - 25th percentile: 110 (lower band)
+    /// - median: 140 (center line)
+    /// - 75th percentile: 170 (upper band)
+    /// - 90th percentile: 196 (upper whisker)
+    /// ```
+    ///
+    /// The resulting statistics are used to show:
+    /// - A dark blue area for the interquartile range (25th-75th percentile)
+    /// - A light blue area for the wider range (10th-90th percentile)
+    /// - A solid blue line for the median
+    func calculateHourlyStatsForGlucoseAreaChart(from ids: [NSManagedObjectID]) async {
+        let taskContext = CoreDataStack.shared.newTaskContext()
+
         let calendar = Calendar.current
 
-        // Fetch glucose values and group them by hour
-        let hourlyGroups = Dictionary(
-            grouping: fetchGlucoseValues(from: ids),
-            by: { calendar.component(.hour, from: $0.date ?? Date()) }
-        )
+        let stats = await taskContext.perform {
+            // Convert IDs to GlucoseStored objects using the context
+            let readings = ids.compactMap { id -> GlucoseStored? in
+                do {
+                    return try taskContext.existingObject(with: id) as? GlucoseStored
+                } catch {
+                    debugPrint("\(DebuggingIdentifiers.failed) Error fetching glucose: \(error)")
+                    return nil
+                }
+            }
+
+            // Group readings by hour of day (0-23)
+            // Example: [8: [reading1, reading2], 9: [reading3, reading4, reading5], ...]
+            let groupedByHour = Dictionary(grouping: readings) { reading in
+                calendar.component(.hour, from: reading.date ?? Date())
+            }
+
+            // Process each hour of the day (0-23)
+            return (0 ... 23).map { hour in
+                // Get all readings for this hour (or empty if none)
+                let readings = groupedByHour[hour] ?? []
 
-        // Calculate stats for each hour (0-23)
-        return (0 ... 23).map { hour in
-            let values = hourlyGroups[hour]?.compactMap { Double($0.glucose) }.sorted() ?? []
-            guard !values.isEmpty else {
-                return HourlyStats(hour: hour, median: 0, percentile25: 0, percentile75: 0, percentile10: 0, percentile90: 0)
+                // Extract and sort glucose values for percentile calculations
+                // Example: [100, 120, 130, 140, 150, 160, 180]
+                let values = readings.map { Double($0.glucose) }.sorted()
+                let count = Double(values.count)
+
+                // Handle hours with no readings
+                guard !values.isEmpty else {
+                    return HourlyStats(
+                        hour: hour,
+                        median: 0,
+                        percentile25: 0,
+                        percentile75: 0,
+                        percentile10: 0,
+                        percentile90: 0
+                    )
+                }
+
+                // Calculate median
+                // For even count: average of two middle values
+                // For odd count: middle value
+                let median = count.isEven ?
+                    (values[Int(count / 2) - 1] + values[Int(count / 2)]) / 2 :
+                    values[Int(count / 2)]
+
+                // Create statistics object with all percentiles
+                // Index calculation: multiply count by desired percentile (0.25 for 25th)
+                return HourlyStats(
+                    hour: hour,
+                    median: median,
+                    percentile25: values[Int(count * 0.25)], // Lower quartile
+                    percentile75: values[Int(count * 0.75)], // Upper quartile
+                    percentile10: values[Int(count * 0.10)], // Lower whisker
+                    percentile90: values[Int(count * 0.90)] // Upper whisker
+                )
             }
+        }
 
-            // Calculate percentiles using array indices
-            let count = values.count
-            return HourlyStats(
-                hour: hour,
-                median: values[count * 50 / 100],
-                percentile25: values[count * 25 / 100],
-                percentile75: values[count * 75 / 100],
-                percentile10: values[count * 10 / 100],
-                percentile90: values[count * 90 / 100]
-            )
+        // Update stats on main thread
+        await MainActor.run {
+            self.hourlyStats = stats
         }
     }
 }

+ 114 - 89
FreeAPS/Sources/Modules/Stat/StatStateModel+Setup/StackedChartSetup.swift

@@ -1,107 +1,132 @@
 import CoreData
 import Foundation
 
-extension Stat {
-    /// Represents a single data point in the glucose range distribution
-    /// - hour: The hour of the day (0-23)
-    /// - count: The percentage of readings in this range for the given hour (0-100)
-    struct GlucoseRangeValue {
-        let hour: Int
-        let count: Double
-    }
+/// Represents the distribution of glucose values within specific ranges for each hour.
+///
+/// This struct is used to visualize how glucose values are distributed across different
+/// ranges (e.g., low, normal, high) throughout the day. Each range has a name and
+/// corresponding hourly values showing the percentage of readings in that range.
+///
+/// Example ranges and their meanings:
+/// - "<54": Urgent low
+/// - "54-70": Low
+/// - "70-140": Target range
+/// - "140-180": High
+/// - "180-200": Very high
+/// - "200-220": Very high+
+/// - ">220": Urgent high
+///
+/// Example usage:
+/// ```swift
+/// let range = GlucoseRangeStats(
+///     name: "70-140",           // Target range
+///     values: [
+///         (hour: 8, count: 75), // 75% of readings at 8 AM were in range
+///         (hour: 9, count: 80)  // 80% of readings at 9 AM were in range
+///     ]
+/// )
+/// ```
+///
+/// This data structure is used to create stacked area charts showing the
+/// distribution of glucose values across different ranges for each hour of the day.
+public struct GlucoseRangeStats: Identifiable {
+    /// The name of the glucose range (e.g., "70-140", "<54")
+    let name: String
 
-    /// Represents the distribution of glucose values within specific ranges for each hour
-    ///
-    /// This struct is used to visualize how glucose values are distributed across different
-    /// ranges (e.g., low, target, high) throughout the day. Each range has a name and
-    /// corresponding hourly values showing the percentage of readings in that range.
-    ///
-    /// Example ranges and their meanings:
-    /// - "<54": Urgent low
-    /// - "54-70": Low
-    /// - "70-140": Target range
-    /// - "140-180": High
-    /// - "180-200": Very high
-    /// - "200-220": Very high+
-    /// - ">220": Urgent high
-    struct GlucoseRangeStats: Identifiable {
-        let id = UUID()
-        /// The name of the glucose range (e.g., "70-140", "<54")
-        let name: String
-        /// Array of hourly values containing percentages for this range
-        let values: [GlucoseRangeValue]
-    }
+    /// Array of tuples containing the hour and percentage of readings in this range
+    /// - hour: Hour of the day (0-23)
+    /// - count: Percentage of readings in this range for the given hour (0-100)
+    let values: [(hour: Int, count: Int)]
+
+    /// Unique identifier for the range, derived from its name
+    public var id: String { name }
 }
 
 extension Stat.StateModel {
-    /// Calculates range statistics for grouped glucose values
-    /// - Parameter groupedValues: Dictionary with dates as keys and arrays of glucose readings as values
-    /// - Returns: Dictionary with dates as keys and arrays of range statistics as values
-    ///
-    /// This function processes glucose readings grouped by date to calculate the distribution
-    /// of values across different ranges for each hour of the day.
-    func calculateRangeStats(
-        for groupedValues: [Date: [GlucoseStored]]
-    ) -> [Date: [Stat.GlucoseRangeStats]] {
-        groupedValues.mapValues { values in
-            calculateGlucoseRangeStats(from: values.map(\.objectID))
-        }
-    }
-
-    /// Calculates the distribution of glucose values across different ranges for each hour
-    /// - Parameter ids: Array of NSManagedObjectIDs for glucose readings
-    /// - Returns: Array of GlucoseRangeStats containing percentage distributions
+    /// Calculates hourly glucose range distribution statistics.
+    /// The calculation runs asynchronously using the CoreData context.
     ///
-    /// The calculation process:
-    /// 1. Groups readings by hour of day
-    /// 2. Defines glucose ranges and their conditions
-    /// 3. For each range and hour:
-    ///    - Counts readings that fall within the range
-    ///    - Calculates percentage of total readings in that range
+    /// The calculation works as follows:
+    /// 1. Count unique days for each hour to handle missing data
+    /// 2. For each glucose range and hour:
+    ///    - Count readings in that range
+    ///    - Calculate percentage based on number of days with readings
     ///
-    /// The results are used to create stacked area charts showing:
-    /// - Distribution of glucose values across ranges
-    /// - Patterns in glucose control throughout the day
-    /// - Time spent in different ranges for each hour
-    func calculateGlucoseRangeStats(from ids: [NSManagedObjectID]) -> [Stat.GlucoseRangeStats] {
+    /// Example:
+    /// If we have data for 7 days and at 6:00 AM:
+    /// - 3 days had readings in range 70-140
+    /// - 2 days had readings in range 140-180
+    /// - 2 day had a reading in range 180-200
+    /// Then for 6:00 AM:
+    /// - 70-140 = (3/7)*100 = 42.9%
+    /// - 140-180 = (2/7)*100 = 28.6%
+    /// - 180-200 = (2/7)*100 = 28.6%
+    func calculateGlucoseRangeStatsForStackedChart(from ids: [NSManagedObjectID]) async {
+        let taskContext = CoreDataStack.shared.newTaskContext()
+
         let calendar = Calendar.current
 
-        // Group glucose values by hour
-        let hourlyGroups = Dictionary(
-            grouping: fetchGlucoseValues(from: ids),
-            by: { calendar.component(.hour, from: $0.date ?? Date()) }
-        )
+        let stats = await taskContext.perform {
+            // Convert IDs to GlucoseStored objects using the context
+            let readings = ids.compactMap { id -> GlucoseStored? in
+                do {
+                    return try taskContext.existingObject(with: id) as? GlucoseStored
+                } catch {
+                    debugPrint("\(DebuggingIdentifiers.failed) Error fetching glucose: \(error)")
+                    return nil
+                }
+            }
 
-        // Prepare hourly values for processing
-        let hourlyValues = (0 ... 23).map { hour -> (hour: Int, values: [Double]) in
-            let values = hourlyGroups[hour]?.compactMap { Double($0.glucose) } ?? []
-            return (hour, values)
-        }
+            // Count unique days for each hour
+            let daysPerHour = (0 ... 23).map { hour in
+                let uniqueDays = Set(readings.compactMap { reading -> Date? in
+                    guard let date = reading.date else { return nil }
+                    if calendar.component(.hour, from: date) == hour {
+                        return calendar.startOfDay(for: date)
+                    }
+                    return nil
+                })
+                return (hour: hour, days: uniqueDays.count)
+            }
 
-        // Define glucose ranges and their conditions
-        let ranges: [(name: String, filter: (Double) -> Bool)] = [
-            ("<54", { [self] in $0 < Double(self.lowLimit - 20) }),
-            ("54-70", { [self] in $0 >= Double(self.lowLimit - 20) && $0 < Double(self.lowLimit) }),
-            ("70-140", { [self] in $0 >= Double(self.lowLimit) && $0 <= 140 }),
-            ("140-180", { [self] in $0 > 140 && $0 <= Double(self.highLimit) }),
-            ("180-200", { [self] in $0 > Double(self.highLimit) && $0 <= 200 }),
-            ("200-220", { $0 > 200 && $0 <= 220 }),
-            (">220", { $0 > 220 })
-        ]
+            // Define glucose ranges and their conditions
+            // Ranges are processed from bottom to top in the stacked chart
+            let ranges: [(name: String, condition: (Int) -> Bool)] = [
+                ("<54", { g in g <= 54 }),
+                ("54-70", { g in g > 54 && g < 70 }),
+                ("70-140", { g in g >= 70 && g <= 140 }),
+                ("140-180", { g in g > 140 && g <= 180 }),
+                ("180-200", { g in g > 180 && g <= 200 }),
+                ("200-220", { g in g > 200 && g <= 220 }),
+                (">220", { g in g > 220 })
+            ]
 
-        // Calculate percentage distribution for each range
-        return ranges.map { range in
-            Stat.GlucoseRangeStats(
-                name: range.name,
-                values: hourlyValues.map { hour, values in
-                    let total = Double(values.count)
-                    let count = values.filter(range.filter).count
-                    return Stat.GlucoseRangeValue(
-                        hour: hour,
-                        count: total > 0 ? Double(count) / total : 0
-                    )
+            // Process each range to create the chart data
+            return ranges.map { rangeName, condition in
+                // Calculate values for each hour within this range
+                let hourlyValues = (0 ... 23).map { hour in
+                    let totalDaysForHour = Double(daysPerHour[hour].days)
+                    // Skip if no data for this hour
+                    guard totalDaysForHour > 0 else { return (hour: hour, count: 0) }
+
+                    // Count readings that match the range condition for this hour
+                    let readingsInRange = readings.filter { reading in
+                        guard let date = reading.date else { return false }
+                        return calendar.component(.hour, from: date) == hour &&
+                            condition(Int(reading.glucose))
+                    }.count
+
+                    // Convert to percentage based on number of days with data
+                    let percentage = (Double(readingsInRange) / totalDaysForHour) * 100.0
+                    return (hour: hour, count: Int(percentage))
                 }
-            )
+                return GlucoseRangeStats(name: rangeName, values: hourlyValues)
+            }
+        }
+
+        // Update stats on main thread
+        await MainActor.run {
+            self.glucoseRangeStats = stats
         }
     }
 }

+ 29 - 159
FreeAPS/Sources/Modules/Stat/StatStateModel.swift

@@ -54,22 +54,6 @@ extension Stat {
         var hourlyStats: [HourlyStats] = []
         var glucoseRangeStats: [GlucoseRangeStats] = []
 
-        var glucoseObjectIDs: [NSManagedObjectID] = [] // Cache for NSManagedObjectIDs
-
-        var glucoseScrollPosition = Date() // Scroll position for glucose chart used in updateDisplayedStats()
-
-        // Cache for precalculated stats
-        private var dailyStatsCache: [Date: [HourlyStats]] = [:]
-        private var weeklyStatsCache: [Date: [HourlyStats]] = [:] // Key: Begin of week
-        private var monthlyStatsCache: [Date: [HourlyStats]] = [:] // Key: Begin of month
-        private var totalStatsCache: [HourlyStats] = []
-
-        // Cache for GlucoseRangeStats
-        private var dailyRangeStatsCache: [Date: [GlucoseRangeStats]] = [:]
-        private var weeklyRangeStatsCache: [Date: [GlucoseRangeStats]] = [:]
-        private var monthlyRangeStatsCache: [Date: [GlucoseRangeStats]] = [:]
-        private var totalRangeStatsCache: [GlucoseRangeStats] = []
-
         // Cache for Meal Stats
         var hourlyMealStats: [MealStats] = []
         var dailyMealStats: [MealStats] = []
@@ -86,12 +70,9 @@ extension Stat {
         var bolusAveragesCache: [Date: (manual: Double, smb: Double, external: Double)] = [:]
 
         // Selected Duration for Glucose Stats
-        var selectedDurationForGlucoseStats: StatsTimeInterval = .Day {
+        var selectedDurationForGlucoseStats: Duration = .Today {
             didSet {
-                Task {
-                    await precalculateStats(from: glucoseObjectIDs)
-                    await updateDisplayedStats(for: selectedGlucoseChartType)
-                }
+                setupGlucoseArray(for: selectedDurationForGlucoseStats)
             }
         }
 
@@ -109,13 +90,7 @@ extension Stat {
         }
 
         // Selected Glucose Chart Type
-        var selectedGlucoseChartType: GlucoseChartType = .percentile {
-            didSet {
-                Task {
-                    await updateDisplayedStats(for: selectedGlucoseChartType)
-                }
-            }
-        }
+        var selectedGlucoseChartType: GlucoseChartType = .percentile
 
         // Selected Insulin Chart Type
         var selectedInsulinChartType: InsulinChartType = .totalDailyDose
@@ -188,7 +163,7 @@ extension Stat {
         }
 
         override func subscribe() {
-            setupGlucoseArray()
+            setupGlucoseArray(for: .Today)
             setupTDDStats()
             setupBolusStats()
             setupLoopStatRecords()
@@ -200,24 +175,38 @@ extension Stat {
             timeInRangeChartStyle = settingsManager.settings.timeInRangeChartStyle
         }
 
-        /// Initializes the glucose array and calculates initial statistics
-        func setupGlucoseArray() {
+        func setupGlucoseArray(for duration: Duration) {
             Task {
-                let ids = await fetchGlucose()
+                let ids = await fetchGlucose(for: duration)
                 await updateGlucoseArray(with: ids)
-                await precalculateStats(from: ids)
-                await updateDisplayedStats(for: selectedGlucoseChartType)
+
+                // Calculate hourly stats and glucose range stats asynchronously with fetched glucose IDs
+                async let hourlyStats: () = calculateHourlyStatsForGlucoseAreaChart(from: ids)
+                async let glucoseRangeStats: () = calculateGlucoseRangeStatsForStackedChart(from: ids)
+                _ = await (hourlyStats, glucoseRangeStats)
             }
         }
 
-        /// Fetches glucose readings from CoreData for statistical analysis
-        /// - Returns: Array of NSManagedObjectIDs for glucose readings
-        /// Fetches only the required properties (glucose and objectID) to optimize performance
-        private func fetchGlucose() async -> [NSManagedObjectID] {
+        private func fetchGlucose(for duration: Duration) async -> [NSManagedObjectID] {
+            let predicate: NSPredicate
+
+            switch duration {
+            case .Day:
+                predicate = NSPredicate.glucoseForStatsDay
+            case .Week:
+                predicate = NSPredicate.glucoseForStatsWeek
+            case .Today:
+                predicate = NSPredicate.glucoseForStatsToday
+            case .Month:
+                predicate = NSPredicate.glucoseForStatsMonth
+            case .Total:
+                predicate = NSPredicate.glucoseForStatsTotal
+            }
+
             let results = await CoreDataStack.shared.fetchEntitiesAsync(
                 ofType: GlucoseStored.self,
                 onContext: context,
-                predicate: NSPredicate.glucoseForStatsTotal,
+                predicate: predicate,
                 key: "date",
                 ascending: false,
                 batchSize: 100,
@@ -226,19 +215,16 @@ extension Stat {
 
             return await context.perform {
                 guard let fetchedResults = results as? [[String: Any]] else { return [] }
+
                 return fetchedResults.compactMap { $0["objectID"] as? NSManagedObjectID }
             }
         }
 
-        /// Updates the glucose array on the main actor with fetched glucose readings
-        /// - Parameter IDs: Array of NSManagedObjectIDs to update from
-        /// Also caches the IDs for later use in statistics calculations
         @MainActor private func updateGlucoseArray(with IDs: [NSManagedObjectID]) {
             do {
                 let glucoseObjects = try IDs.compactMap { id in
                     try viewContext.existingObject(with: id) as? GlucoseStored
                 }
-                glucoseObjectIDs = IDs // Cache IDs for later use
                 glucoseFromPersistence = glucoseObjects
             } catch {
                 debugPrint(
@@ -246,122 +232,6 @@ extension Stat {
                 )
             }
         }
-
-        /// Precalculates statistics for both chart types (percentile and distribution) based on the selected time interval
-        /// - Parameter ids: Array of NSManagedObjectIDs for glucose readings
-        /// This function groups glucose values by the selected time interval (day/week/month/total)
-        /// and calculates both hourly statistics and range distributions for each group
-        private func precalculateStats(from ids: [NSManagedObjectID]) async {
-            await context.perform { [self] in
-                let glucoseValues = fetchGlucoseValues(from: ids)
-
-                // Group glucose values based on selected time interval
-                let groupedValues = groupGlucoseValues(glucoseValues, for: selectedDurationForGlucoseStats)
-
-                // Calculate and cache statistics based on time interval
-                switch selectedDurationForGlucoseStats {
-                case .Day:
-                    dailyStatsCache = calculateStats(for: groupedValues)
-                    dailyRangeStatsCache = calculateRangeStats(for: groupedValues)
-
-                case .Week:
-                    weeklyStatsCache = calculateStats(for: groupedValues)
-                    weeklyRangeStatsCache = calculateRangeStats(for: groupedValues)
-
-                case .Month:
-                    monthlyStatsCache = calculateStats(for: groupedValues)
-                    monthlyRangeStatsCache = calculateRangeStats(for: groupedValues)
-
-                case .Total:
-                    totalStatsCache = calculateHourlyStats(from: ids)
-                    totalRangeStatsCache = calculateGlucoseRangeStats(from: ids)
-                }
-            }
-        }
-
-        /// Groups glucose values based on the selected time interval
-        /// - Parameters:
-        ///   - values: Array of glucose readings
-        ///   - interval: Selected time interval (day/week/month)
-        /// - Returns: Dictionary with date as key and array of glucose readings as value
-        private func groupGlucoseValues(
-            _ values: [GlucoseStored],
-            for interval: StatsTimeInterval
-        ) -> [Date: [GlucoseStored]] {
-            let calendar = Calendar.current
-
-            switch interval {
-            case .Day:
-                return Dictionary(grouping: values) {
-                    calendar.startOfDay(for: $0.date ?? Date())
-                }
-            case .Week:
-                return Dictionary(grouping: values) {
-                    calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: $0.date ?? Date()))!
-                }
-            case .Month:
-                return Dictionary(grouping: values) {
-                    calendar.date(from: calendar.dateComponents([.year, .month], from: $0.date ?? Date()))!
-                }
-            case .Total:
-                return [:] // Not used for total stats
-            }
-        }
-
-        /// Helper function to safely fetch glucose values from CoreData
-        /// - Parameter ids: Array of NSManagedObjectIDs
-        /// - Returns: Array of GlucoseStored objects
-        func fetchGlucoseValues(from ids: [NSManagedObjectID]) -> [GlucoseStored] {
-            ids.compactMap { id -> GlucoseStored? in
-                do {
-                    return try context.existingObject(with: id) as? GlucoseStored
-                } catch let error as NSError {
-                    debugPrint("\(DebuggingIdentifiers.failed) Error fetching glucose: \(error.userInfo)")
-                    return nil
-                }
-            }
-        }
-
-        /// Updates the displayed statistics based on the selected chart type and time interval
-        /// - Parameter chartType: The type of chart being displayed (percentile or distribution)
-        @MainActor func updateDisplayedStats(for chartType: GlucoseChartType) {
-            let calendar = Calendar.current
-
-            // Get the appropriate start date based on the selected time interval
-            let startDate: Date = {
-                switch selectedDurationForGlucoseStats {
-                case .Day:
-                    return calendar.startOfDay(for: glucoseScrollPosition)
-                case .Week:
-                    return calendar
-                        .date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: glucoseScrollPosition))!
-                case .Month:
-                    return calendar.date(from: calendar.dateComponents([.year, .month], from: glucoseScrollPosition))!
-                case .Total:
-                    return glucoseScrollPosition
-                }
-            }()
-
-            // Update the appropriate stats based on chart type
-            switch (selectedDurationForGlucoseStats, chartType) {
-            case (.Day, .percentile):
-                hourlyStats = dailyStatsCache[startDate] ?? []
-            case (.Day, .distribution):
-                glucoseRangeStats = dailyRangeStatsCache[startDate] ?? []
-            case (.Week, .percentile):
-                hourlyStats = weeklyStatsCache[startDate] ?? []
-            case (.Week, .distribution):
-                glucoseRangeStats = weeklyRangeStatsCache[startDate] ?? []
-            case (.Month, .percentile):
-                hourlyStats = monthlyStatsCache[startDate] ?? []
-            case (.Month, .distribution):
-                glucoseRangeStats = monthlyRangeStatsCache[startDate] ?? []
-            case (.Total, .percentile):
-                hourlyStats = totalStatsCache
-            case (.Total, .distribution):
-                glucoseRangeStats = totalRangeStatsCache
-            }
-        }
     }
 
     @Observable final class UpdateTimer {

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

@@ -76,7 +76,7 @@ extension Stat {
             }.padding(.horizontal)
 
             Picker("Duration", selection: $state.selectedDurationForGlucoseStats) {
-                ForEach(StateModel.StatsTimeInterval.allCases, id: \.self) { timeInterval in
+                ForEach(StateModel.Duration.allCases, id: \.self) { timeInterval in
                     Text(timeInterval.rawValue)
                 }
             }
@@ -100,18 +100,15 @@ extension Stat {
                     switch state.selectedGlucoseChartType {
                     case .percentile:
                         GlucosePercentileChart(
-                            selectedDuration: $state.selectedDurationForGlucoseStats,
-                            state: state,
                             glucose: state.glucoseFromPersistence,
                             highLimit: state.highLimit,
                             lowLimit: state.lowLimit,
                             units: state.units,
-                            hourlyStats: state.hourlyStats
+                            hourlyStats: state.hourlyStats,
+                            isToday: state.selectedDurationForGlucoseStats == .Today
                         )
                     case .distribution:
                         GlucoseDistributionChart(
-                            selectedDuration: $state.selectedDurationForGlucoseStats,
-                            state: state,
                             glucose: state.glucoseFromPersistence,
                             highLimit: state.highLimit,
                             lowLimit: state.lowLimit,

+ 10 - 80
FreeAPS/Sources/Modules/Stat/View/ViewElements/GlucoseDistributionChart.swift

@@ -2,78 +2,16 @@ import Charts
 import SwiftUI
 
 struct GlucoseDistributionChart: View {
-    @Binding var selectedDuration: Stat.StateModel.StatsTimeInterval
-    let state: Stat.StateModel
     let glucose: [GlucoseStored]
     let highLimit: Decimal
     let lowLimit: Decimal
     let units: GlucoseUnits
-    let glucoseRangeStats: [Stat.GlucoseRangeStats]
-
-    @State private var scrollPosition = Date()
-    @State private var selection: Date?
-
-    private var visibleDateRange: (start: Date, end: Date) {
-        let calendar = Calendar.current
-        return (
-            calendar.startOfDay(for: scrollPosition),
-            calendar.startOfDay(for: scrollPosition).addingTimeInterval(24 * 3600)
-        )
-    }
-
-    private func formatVisibleDateRange() -> String {
-        let calendar = Calendar.current
-        let today = Date()
-
-        switch selectedDuration {
-        case .Day:
-            let isToday = calendar.isDate(scrollPosition, inSameDayAs: today)
-            let isYesterday = calendar.isDate(
-                scrollPosition,
-                inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!
-            )
-
-            return if isToday {
-                "Today"
-            } else if isYesterday {
-                "Yesterday"
-            } else {
-                scrollPosition.formatted(date: .numeric, time: .omitted)
-            }
-
-        case .Week:
-            let weekStart = calendar
-                .date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: scrollPosition))!
-            let weekEnd = calendar.date(byAdding: .day, value: 6, to: weekStart)!
-            return "\(weekStart.formatted(date: .numeric, time: .omitted)) - \(weekEnd.formatted(date: .numeric, time: .omitted))"
-
-        case .Month:
-            let monthStart = calendar.date(
-                from: calendar.dateComponents([.year, .month], from: scrollPosition)
-            )!
-            let monthEnd = calendar.date(byAdding: .month, value: 1, to: monthStart)!
-            let lastDayOfMonth = calendar.date(byAdding: .day, value: -1, to: monthEnd)!
-            return "\(monthStart.formatted(date: .numeric, time: .omitted)) - \(lastDayOfMonth.formatted(date: .numeric, time: .omitted))"
-
-        case .Total:
-            let endDate = scrollPosition
-            let startDate = calendar.date(byAdding: .month, value: -3, to: endDate)!
-            return "\(startDate.formatted(date: .numeric, time: .omitted)) - \(endDate.formatted(date: .numeric, time: .omitted))"
-        }
-    }
+    let glucoseRangeStats: [GlucoseRangeStats]
 
     var body: some View {
         VStack(alignment: .leading, spacing: 8) {
-            HStack(alignment: .top) {
-                Text("Glucose Distribution")
-                    .font(.headline)
-
-                Spacer()
-
-                Text(formatVisibleDateRange())
-                    .font(.subheadline)
-                    .foregroundStyle(.secondary)
-            }
+            Text("Glucose Distribution")
+                .font(.headline)
 
             Chart(glucoseRangeStats) { range in
                 ForEach(range.values, id: \.hour) { value in
@@ -95,7 +33,13 @@ struct GlucoseDistributionChart: View {
                 ">220": .orange.opacity(0.8)
             ])
             .chartYAxis {
-                AxisMarks(position: .trailing)
+                AxisMarks(position: .leading)
+            }
+            .chartYAxisLabel(alignment: .leading) {
+                Text("Percentage")
+                    .foregroundStyle(.primary)
+                    .font(.caption)
+                    .padding(.vertical, 3)
             }
             .chartXAxis {
                 AxisMarks(values: .stride(by: .hour, count: 3)) { _ in
@@ -103,21 +47,7 @@ struct GlucoseDistributionChart: View {
                     AxisGridLine()
                 }
             }
-            .chartScrollableAxes(.horizontal)
-            .chartScrollPosition(x: $scrollPosition)
-            .chartXSelection(value: $selection)
-            .chartXVisibleDomain(length: 24 * 3600)
-            .chartScrollTargetBehavior(
-                .valueAligned(
-                    matching: DateComponents(minute: 0),
-                    majorAlignment: .matching(DateComponents(hour: 0))
-                )
-            )
             .frame(height: 200)
         }
-        .onChange(of: scrollPosition) {
-            state.glucoseScrollPosition = scrollPosition
-            state.updateDisplayedStats(for: .distribution)
-        }
     }
 }

+ 101 - 174
FreeAPS/Sources/Modules/Stat/View/ViewElements/GlucosePercentileChart.swift

@@ -2,111 +2,32 @@ import Charts
 import SwiftUI
 
 struct GlucosePercentileChart: View {
-    @Binding var selectedDuration: Stat.StateModel.StatsTimeInterval
-    let state: Stat.StateModel
     let glucose: [GlucoseStored]
     let highLimit: Decimal
     let lowLimit: Decimal
     let units: GlucoseUnits
     let hourlyStats: [HourlyStats]
+    let isToday: Bool
 
-    @State private var scrollPosition = Date()
-    @State private var selection: Date?
-    @State private var isScrolling = false
-    @State private var updateTimer = Stat.UpdateTimer()
+    @State private var selection: Date? = nil
 
-    private func getDataRange() -> (start: Date, end: Date) {
-        let calendar = Calendar.current
+    private var selectedStats: HourlyStats? {
+        guard let selection = selection else { return nil }
 
-        switch selectedDuration {
-        case .Day:
-            return (
-                calendar.startOfDay(for: scrollPosition),
-                calendar.startOfDay(for: scrollPosition).addingTimeInterval(24 * 3600)
-            )
-        case .Week:
-            let weekStart = calendar
-                .date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: scrollPosition))!
-            return (weekStart, weekStart.addingTimeInterval(7 * 24 * 3600))
-        case .Month:
-            let monthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: scrollPosition))!
-            return (monthStart, calendar.date(byAdding: .month, value: 1, to: monthStart)!)
-        case .Total:
-            return (
-                calendar.date(byAdding: .month, value: -3, to: scrollPosition)!,
-                scrollPosition
-            )
+        // Don't show stats for future times if viewing today
+        if isToday && selection > Date() {
+            return nil
         }
-    }
-
-    private var visibleDateRange: (start: Date, end: Date) {
-        let calendar = Calendar.current
-
-        // Die X-Achse zeigt immer einen 24h-Tag
-        return (
-            calendar.startOfDay(for: scrollPosition),
-            calendar.startOfDay(for: scrollPosition).addingTimeInterval(24 * 3600)
-        )
-    }
 
-    private func formatVisibleDateRange() -> String {
         let calendar = Calendar.current
-        let today = Date()
-
-        switch selectedDuration {
-        case .Day:
-            let isToday = calendar.isDate(scrollPosition, inSameDayAs: today)
-            let isYesterday = calendar.isDate(
-                scrollPosition,
-                inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!
-            )
-
-            return if isToday {
-                "Today"
-            } else if isYesterday {
-                "Yesterday"
-            } else {
-                scrollPosition.formatted(date: .numeric, time: .omitted)
-            }
-
-        case .Week:
-            let weekStart = calendar
-                .date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: scrollPosition))!
-            let weekEnd = calendar.date(byAdding: .day, value: 6, to: weekStart)!
-            return "\(weekStart.formatted(date: .numeric, time: .omitted)) - \(weekEnd.formatted(date: .numeric, time: .omitted))"
-
-        case .Month:
-            let monthStart = calendar.date(
-                from: calendar.dateComponents([.year, .month], from: scrollPosition)
-            )!
-            let monthEnd = calendar.date(byAdding: .month, value: 1, to: monthStart)!
-            let lastDayOfMonth = calendar.date(byAdding: .day, value: -1, to: monthEnd)!
-            return "\(monthStart.formatted(date: .numeric, time: .omitted)) - \(lastDayOfMonth.formatted(date: .numeric, time: .omitted))"
-
-        case .Total:
-            let endDate = scrollPosition
-            let startDate = calendar.date(byAdding: .month, value: -3, to: endDate)!
-            return "\(startDate.formatted(date: .numeric, time: .omitted)) - \(endDate.formatted(date: .numeric, time: .omitted))"
-        }
+        let hour = calendar.component(.hour, from: selection)
+        return hourlyStats.first { Int($0.hour) == hour }
     }
 
     var body: some View {
         VStack(alignment: .leading, spacing: 8) {
-            HStack(alignment: .top) {
-                VStack(alignment: .leading) {
-                    Text("Ambulatory Glucose Profile")
-                        .font(.headline)
-                    Text("(AGP)")
-                        .font(.subheadline)
-                        .foregroundStyle(.secondary)
-                }
-
-                Spacer()
-
-                Text(formatVisibleDateRange())
-                    .font(.subheadline)
-                    .foregroundStyle(.secondary)
-            }
+            Text("Ambulatory Glucose Profile (AGP)")
+                .font(.headline)
 
             Chart {
                 // TODO: ensure data is still correct
@@ -116,7 +37,7 @@ struct GlucosePercentileChart: View {
                 // 10-90 percentile area
                 ForEach(hourlyStats, id: \.hour) { stats in
                     AreaMark(
-                        x: .value("Hour", Calendar.current.dateForChartHour(stats.hour), unit: .hour),
+                        x: .value("Hour", Calendar.current.dateForChartHour(stats.hour)),
                         yStart: .value("10th Percentile", stats.percentile10),
                         yEnd: .value("90th Percentile", stats.percentile90),
                         series: .value("10-90", "10-90")
@@ -127,7 +48,7 @@ struct GlucosePercentileChart: View {
                 // 25-75 percentile area
                 ForEach(hourlyStats, id: \.hour) { stats in
                     AreaMark(
-                        x: .value("Hour", Calendar.current.dateForChartHour(stats.hour), unit: .hour),
+                        x: .value("Hour", Calendar.current.dateForChartHour(stats.hour)),
                         yStart: .value("25th Percentile", stats.percentile25),
                         yEnd: .value("75th Percentile", stats.percentile75),
                         series: .value("25-75", "25-75")
@@ -138,7 +59,7 @@ struct GlucosePercentileChart: View {
                 // Median line
                 ForEach(hourlyStats.filter { $0.median > 0 }, id: \.hour) { stats in
                     LineMark(
-                        x: .value("Hour", Calendar.current.dateForChartHour(stats.hour), unit: .hour),
+                        x: .value("Hour", Calendar.current.dateForChartHour(stats.hour)),
                         y: .value("Median", stats.median),
                         series: .value("Median", "Median")
                     )
@@ -146,104 +67,110 @@ struct GlucosePercentileChart: View {
                     .foregroundStyle(.blue)
                 }
 
-                // Target range
-                RuleMark(
-                    y: .value("High Limit", highLimit)
-                )
-                .lineStyle(StrokeStyle(lineWidth: 2, dash: [10, 5]))
-                .foregroundStyle(.orange.gradient)
-
-                // TODO: - Get target
-                RuleMark(
-                    y: .value("Target", 100)
-                )
-                .lineStyle(StrokeStyle(lineWidth: 1.5))
-                .foregroundStyle(.green.gradient)
-
-                RuleMark(
-                    y: .value("Low Limit", lowLimit)
-                )
-                .lineStyle(StrokeStyle(lineWidth: 2, dash: [10, 5]))
-                .foregroundStyle(.red.gradient)
-
-                if let selection = selection,
-                   let stats = selectedStats
-                {
-                    RuleMark(
-                        x: .value("Selected Time", selection)
-                    )
-                    .foregroundStyle(.secondary.opacity(0.3))
-                    .annotation(
-                        position: .top,
-                        spacing: 0,
-                        overflowResolution: .init(x: .fit, y: .disabled)
-                    ) {
-                        AGPSelectionPopover(
-                            stats: stats,
-                            time: selection,
-                            units: units
-                        )
-                    }
+                // High/Low limit lines
+                RuleMark(y: .value("High Limit", Double(highLimit)))
+                    .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
+                    .foregroundStyle(.orange)
+
+                RuleMark(y: .value("Low Limit", Double(lowLimit)))
+                    .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
+                    .foregroundStyle(.red)
+
+                if let selectedStats, let selection {
+                    RuleMark(x: .value("Selection", selection))
+                        .foregroundStyle(.secondary.opacity(0.3))
+                        .annotation(
+                            position: .top,
+                            spacing: 0,
+                            overflowResolution: .init(x: .fit, y: .disabled)
+                        ) {
+                            AGPSelectionPopover(
+                                stats: selectedStats,
+                                time: selection,
+                                units: units
+                            )
+                        }
                 }
             }
             .chartYAxis {
-                AxisMarks(position: .trailing) { value in
-                    if let glucose = value.as(Double.self) {
-                        let glucoseValue = units == .mmolL ? Decimal(glucose).asMmolL : Decimal(glucose)
-                        AxisValueLabel {
-                            Text(glucoseValue.formatted(.number.precision(.fractionLength(units == .mmolL ? 1 : 0))))
-                        }
-                        AxisGridLine()
-                    }
-                }
+                AxisMarks(position: .leading)
+            }
+            .chartYAxisLabel(alignment: .leading) {
+                Text("\(units.rawValue)")
+                    .foregroundStyle(.primary)
+                    .font(.caption)
+                    .padding(.vertical, 3)
             }
             .chartXAxis {
                 AxisMarks(values: .stride(by: .hour, count: 3)) { _ in
-                    AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .narrow)), centered: true, anchor: .top)
+                    AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .narrow)), anchor: .top)
                     AxisGridLine()
                 }
             }
-            .chartScrollableAxes(.horizontal)
-            .chartScrollPosition(x: $scrollPosition)
             .chartXSelection(value: $selection)
-            .chartXVisibleDomain(length: 24 * 3600)
-            .chartScrollTargetBehavior(
-                .valueAligned(
-                    matching: DateComponents(minute: 0),
-                    majorAlignment: .matching(DateComponents(hour: 0))
-                )
-            )
             .frame(height: 200)
-        }
-        // Update chart when scrolling
-        .onChange(of: scrollPosition) {
-            state.glucoseScrollPosition = scrollPosition
-            state.updateDisplayedStats(for: .percentile)
-        }
-        // Reset scroll position when duration changes
-        .onChange(of: selectedDuration) {
-            scrollPosition = Date()
-            state.glucoseScrollPosition = scrollPosition
+
+            legend
         }
     }
 
-    private var selectedStats: HourlyStats? {
-        guard let selection = selection else { return nil }
+    private var legend: some View {
+        HStack(spacing: 20) {
+            VStack {
+                // 10-90 Percentile
+                HStack(spacing: 8) {
+                    Rectangle()
+                        .frame(width: 20, height: 8)
+                        .foregroundStyle(.blue.opacity(0.2))
+                    Text("10% - 90%")
+                        .font(.caption)
+                        .foregroundStyle(.secondary)
+                }
 
-        // Don't show stats for future times if viewing today
-        if isToday && selection > Date() {
-            return nil
-        }
+                // 25-75 Percentile
+                HStack(spacing: 8) {
+                    Rectangle()
+                        .frame(width: 20, height: 8)
+                        .foregroundStyle(.blue.opacity(0.3))
+                    Text("25% - 75%")
+                        .font(.caption)
+                        .foregroundStyle(.secondary)
+                }
+            }
 
-        let calendar = Calendar.current
-        let hour = calendar.component(.hour, from: selection)
-        return hourlyStats.first { Int($0.hour) == hour }
-    }
+            // Median
+            HStack(spacing: 8) {
+                Rectangle()
+                    .frame(width: 20, height: 2)
+                    .foregroundStyle(.blue)
+                Text("Median")
+                    .font(.caption)
+                    .foregroundStyle(.secondary)
+            }
 
-    private var isToday: Bool {
-        let calendar = Calendar.current
-        let now = Date()
-        return calendar.isDate(now, inSameDayAs: calendar.startOfDay(for: now))
+            VStack {
+                // High Limit
+                HStack(spacing: 8) {
+                    Rectangle()
+                        .frame(width: 20, height: 1)
+                        .foregroundStyle(.orange)
+                    Text("High Limit")
+                        .font(.caption)
+                        .foregroundStyle(.secondary)
+                }
+
+                // Low Limit
+                HStack(spacing: 8) {
+                    Rectangle()
+                        .frame(width: 20, height: 1)
+                        .foregroundStyle(.red)
+                    Text("Low Limit")
+                        .font(.caption)
+                        .foregroundStyle(.secondary)
+                }
+            }
+        }
+        .padding(.horizontal)
     }
 }