Przeglądaj źródła

Rework AGP + Glucose Distribution charts, update docstrings, refactoring

polscm32 aka Marvout 1 rok temu
rodzic
commit
e18cfca555

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

@@ -10,8 +10,13 @@ import Foundation
 /// - The 25th and 75th percentiles form the interquartile range (IQR)
 /// - The 10th and 90th percentiles show the wider range of values
 ///
-/// Example usage in visualization:
-/// ```
+/// 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
 /// let stats = HourlyStats(
 ///     hour: 14,        // 2 PM
 ///     median: 120,     // Center line
@@ -40,105 +45,69 @@ 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 statistical values (median, percentiles) from glucose readings.
-    /// The calculation runs asynchronously using the CoreData context.
+    /// 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
     ///
-    /// The calculation works as follows:
-    /// 1. Group readings by hour of day (0-23)
-    /// 2. For each hour:
-    ///    - Sort glucose values
-    ///    - Calculate median (50th percentile)
-    ///    - Calculate 10th, 25th, 75th, and 90th percentiles
+    /// 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
     ///
-    /// 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 calculation process:
+    /// 1. Groups 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
     ///
-    /// 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()
-
+    /// 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] {
         let calendar = Calendar.current
 
-        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] ?? []
+        // Fetch glucose values and group them by hour
+        let hourlyGroups = Dictionary(
+            grouping: fetchGlucoseValues(from: ids),
+            by: { calendar.component(.hour, from: $0.date ?? Date()) }
+        )
 
-                // 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 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)
             }
-        }
 
-        // Update stats on main thread
-        await MainActor.run {
-            self.hourlyStats = stats
+            // 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]
+            )
         }
     }
 }

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

@@ -15,6 +15,12 @@ struct BolusStats: Identifiable {
 }
 
 extension Stat.StateModel {
+    /// Initializes and fetches bolus statistics
+    ///
+    /// This function:
+    /// 1. Fetches bolus records from CoreData
+    /// 2. Groups and processes the records into bolus statistics
+    /// 3. Updates the bolusStats array on the main thread
     func setupBolusStats() {
         Task {
             let stats = await fetchBolusStats()
@@ -88,6 +94,16 @@ extension Stat.StateModel {
         }
     }
 
+    /// Calculates the average daily insulin amounts for manual boluses, SMB (Super Micro Boluses), and external boluses
+    /// within the specified date range
+    /// - Parameters:
+    ///   - startDate: The beginning date of the period to calculate averages for (inclusive)
+    ///   - endDate: The ending date of the period to calculate averages for (inclusive)
+    /// - Returns: A tuple containing three values:
+    ///   - manual: Average daily amount of manual boluses
+    ///   - smb: Average daily amount of Super Micro Boluses (SMB)
+    ///   - external: Average daily amount of external boluses (entered directly on pump)
+    /// - Note: Returns (0, 0, 0) if no data exists for the specified date range
     func calculateAverageBolus(from startDate: Date, to endDate: Date) -> (manual: Double, smb: Double, external: Double) {
         let visibleStats = bolusStats.filter { stat in
             stat.date >= startDate && stat.date <= endDate

+ 26 - 3
FreeAPS/Sources/Modules/Stat/StatStateModel+Setup/MealStatsSetup.swift

@@ -15,8 +15,12 @@ struct MealStats: Identifiable {
 }
 
 extension Stat.StateModel {
-    /// Initiates the process of fetching and processing meal statistics
-    /// - Parameter duration: The time period to fetch records for
+    /// Initializes and fetches meal statistics
+    ///
+    /// This function:
+    /// 1. Fetches carbohydrate records from CoreData
+    /// 2. Groups and processes the records into meal statistics
+    /// 3. Updates the mealStats array on the main thread
     func setupMealStats() {
         Task {
             let stats = await fetchMealStats()
@@ -27,8 +31,16 @@ 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
+    ///
+    /// This function:
+    /// 1. Fetches carbohydrate entries from CoreData
+    /// 2. Groups entries by day or hour based on selected duration
+    /// 3. Calculates total macronutrients for each time period
+    ///
+    /// The grouping logic:
+    /// - For Day view: Groups by hour to show meal distribution
+    /// - For other views: Groups by day to show daily totals
     private func fetchMealStats() async -> [MealStats] {
         // Fetch CarbEntryStored entries from Core Data
         let results = await CoreDataStack.shared.fetchEntitiesAsync(
@@ -79,6 +91,17 @@ extension Stat.StateModel {
         }
     }
 
+    /// Calculates average meal statistics for a specified date range
+    /// - Parameters:
+    ///   - startDate: Start date of the range
+    ///   - endDate: End date of the range
+    /// - Returns: Tuple containing average values for carbs, fat, and protein
+    ///
+    /// The calculation process:
+    /// 1. Filters meal records within the date range
+    /// 2. Calculates total values for each macronutrient
+    /// 3. Divides totals by number of records to get averages
+    /// 4. Returns (0,0,0) if no records are found
     func calculateAverageMealStats(
         from startDate: Date,
         to endDate: Date

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

@@ -1,132 +1,107 @@
 import CoreData
 import Foundation
 
-/// 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
-
-    /// 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)]
+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
+    }
 
-    /// Unique identifier for the range, derived from its name
-    public var id: String { name }
+    /// 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]
+    }
 }
 
 extension Stat.StateModel {
-    /// Calculates hourly glucose range distribution statistics.
-    /// The calculation runs asynchronously using the CoreData context.
-    ///
-    /// 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
+    /// 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
     ///
-    /// 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()
+    /// 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
+    ///
+    /// 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 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] {
         let calendar = Calendar.current
 
-        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
-                }
-            }
-
-            // 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)
-            }
+        // Group glucose values by hour
+        let hourlyGroups = Dictionary(
+            grouping: fetchGlucoseValues(from: ids),
+            by: { calendar.component(.hour, from: $0.date ?? Date()) }
+        )
 
-            // 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 })
-            ]
-
-            // 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) }
+        // 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 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
+        // 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 })
+        ]
 
-                    // Convert to percentage based on number of days with data
-                    let percentage = (Double(readingsInRange) / totalDaysForHour) * 100.0
-                    return (hour: hour, count: Int(percentage))
+        // 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
+                    )
                 }
-                return GlucoseRangeStats(name: rangeName, values: hourlyValues)
-            }
-        }
-
-        // Update stats on main thread
-        await MainActor.run {
-            self.glucoseRangeStats = stats
+            )
         }
     }
 }

+ 49 - 0
FreeAPS/Sources/Modules/Stat/StatStateModel+Setup/TDDSetup.swift

@@ -2,6 +2,12 @@ import CoreData
 import Foundation
 
 extension Stat.StateModel {
+    /// Initializes and fetches Total Daily Dose (TDD) statistics
+    ///
+    /// This function:
+    /// 1. Fetches TDD determinations from CoreData
+    /// 2. Maps the determinations to TDD records
+    /// 3. Updates the tddStats array on the main thread
     func setupTDDs() {
         Task {
             let tddStats = await fetchAndMapDeterminations()
@@ -11,6 +17,17 @@ extension Stat.StateModel {
         }
     }
 
+    /// Fetches and processes OpenAPS determinations to calculate Total Daily Doses
+    /// - Returns: Array of TDD records sorted by date
+    ///
+    /// This function:
+    /// 1. Fetches OpenAPS determinations from CoreData
+    /// 2. Groups determinations by time period (day or hour based on selected duration)
+    /// 3. Calculates average insulin doses for each time period
+    ///
+    /// The grouping logic:
+    /// - For Day view: Groups by hour to show hourly distribution
+    /// - For other views: Groups by day to show daily totals
     func fetchAndMapDeterminations() async -> [TDD] {
         let results = await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
@@ -63,6 +80,15 @@ extension Stat.StateModel {
         }
     }
 
+    /// Calculates the average Total Daily Dose for the currently selected time period
+    ///
+    /// Time periods and their ranges:
+    /// - Day: Last 3 days
+    /// - Week: Last 7 days
+    /// - Month: Last 30 days
+    /// - Total: Last 3 months
+    ///
+    /// Returns 0 if no TDD records are available for the selected period
     var averageTDD: Decimal {
         let calendar = Calendar.current
         let now = Date()
@@ -95,6 +121,17 @@ extension Stat.StateModel {
         return filteredTDDs.isEmpty ? 0 : sum / Decimal(filteredTDDs.count)
     }
 
+    /// Calculates the average Total Daily Dose for a specified date range
+    /// - Parameters:
+    ///   - startDate: Start date of the range
+    ///   - endDate: End date of the range
+    /// - Returns: Average TDD value for the period
+    ///
+    /// The function:
+    /// 1. Filters TDD records within the specified date range
+    /// 2. Calculates the sum of all TDDs in the range
+    /// 3. Returns the average (sum divided by number of records)
+    /// 4. Returns 0 if no records are found
     func calculateAverageTDD(from startDate: Date, to endDate: Date) async -> Decimal {
         let filteredTDDs = tddStats.filter { tdd in
             guard let timestamp = tdd.timestamp else { return false }
@@ -105,6 +142,18 @@ extension Stat.StateModel {
         return filteredTDDs.isEmpty ? 0 : sum / Decimal(filteredTDDs.count)
     }
 
+    /// Calculates the median Total Daily Dose for a specified date range
+    /// - Parameters:
+    ///   - startDate: Start date of the range
+    ///   - endDate: End date of the range
+    /// - Returns: Median TDD value for the period
+    ///
+    /// The calculation process:
+    /// 1. Filters TDD records within the date range
+    /// 2. Sorts all TDD values
+    /// 3. For odd number of values: Returns the middle value
+    /// 4. For even number of values: Returns average of two middle values
+    /// 5. Returns 0 if no records are found
     func calculateMedianTDD(from startDate: Date, to endDate: Date) async -> Decimal {
         let filteredTDDs = tddStats.filter { tdd in
             guard let timestamp = tdd.timestamp else { return false }

+ 251 - 34
FreeAPS/Sources/Modules/Stat/StatStateModel.swift

@@ -5,6 +5,40 @@ import SwiftUI
 import Swinject
 
 extension Stat {
+    /// Defines the available types of glucose charts
+    enum GlucoseChartType: String, CaseIterable {
+        /// Ambulatory Glucose Profile showing percentile ranges
+        case percentile = "Percentile"
+        /// Time-based distribution of glucose ranges
+        case distribution = "Distribution"
+    }
+
+    /// Defines the available types of insulin charts
+    enum InsulinChartType: String, CaseIterable {
+        /// Shows total daily insulin doses
+        case totalDailyDose = "Total Daily Dose"
+        /// Shows distribution of bolus types
+        case bolusDistribution = "Bolus Distribution"
+    }
+
+    /// Defines the available types of looping charts
+    enum LoopingChartType: String, CaseIterable {
+        /// Shows loop completion and success rates
+        case loopingPerformance = "Looping Performance"
+        /// Shows CGM connection status over time
+        case cgmConnectionTrace = "CGM Connection Trace"
+        /// Shows Trio pump uptime statistics
+        case trioUpTime = "Trio Up-Time"
+    }
+
+    /// Defines the available types of meal charts
+    enum MealChartType: String, CaseIterable {
+        /// Shows total meal statistics
+        case totalMeals = "Total Meals"
+        /// Shows correlation between meals and glucose excursions
+        case mealToHypoHyperDistribution = "Meal to Hypo/Hyper"
+    }
+
     @Observable final class StateModel: BaseStateModel<Provider> {
         @ObservationIgnored @Injected() var settings: SettingsManager!
         var highLimit: Decimal = 180
@@ -18,21 +52,67 @@ extension Stat {
         var mealStats: [MealStats] = []
         var tddStats: [TDD] = []
         var bolusStats: [BolusStats] = []
-        var selectedDurationForGlucoseStats: Duration = .Today {
+        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] = []
+
+        // Selected Duration for Glucose Stats
+        var selectedDurationForGlucoseStats: StatsTimeInterval = .Day {
             didSet {
-                setupGlucoseArray(for: selectedDurationForGlucoseStats)
+                Task {
+                    await precalculateStats(from: glucoseObjectIDs)
+                    await updateDisplayedStats(for: selectedGlucoseChartType)
+                }
             }
         }
 
+        // Selected Duration for Insulin Stats
         var selectedDurationForInsulinStats: StatsTimeInterval = .Day
+
+        // Selected Duration for Meal Stats
         var selectedDurationForMealStats: StatsTimeInterval = .Day
 
+        // Selected Duration for Loop Stats
         var selectedDurationForLoopStats: Duration = .Today {
             didSet {
                 setupLoopStatRecords()
             }
         }
 
+        // Selected Glucose Chart Type
+        var selectedGlucoseChartType: GlucoseChartType = .percentile {
+            didSet {
+                Task {
+                    await updateDisplayedStats(for: selectedGlucoseChartType)
+                }
+            }
+        }
+
+        // Selected Insulin Chart Type
+        var selectedInsulinChartType: InsulinChartType = .totalDailyDose
+
+        // Selected Looping Chart Type
+        var selectedLoopingChartType: LoopingChartType = .loopingPerformance
+
+        // Selected Meal Chart Type
+        var selectedMealChartType: MealChartType = .totalMeals
+
+        // Fetching Contexts
         let context = CoreDataStack.shared.newTaskContext()
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext
         let determinationFetchContext = CoreDataStack.shared.newTaskContext()
@@ -40,30 +120,61 @@ extension Stat {
         let mealTaskContext = CoreDataStack.shared.newTaskContext()
         let bolusTaskContext = CoreDataStack.shared.newTaskContext()
 
+        /// Defines the available time periods for duration-based statistics
         enum Duration: String, CaseIterable, Identifiable {
+            /// Current day
             case Today
+            /// Single day view
             case Day = "D"
+            /// Week view
             case Week = "W"
+            /// Month view
             case Month = "M"
+            /// Three month view
             case Total = "3 M"
 
             var id: Self { self }
         }
 
+        /// Defines the available time intervals for statistical analysis
         enum StatsTimeInterval: String, CaseIterable, Identifiable {
+            /// Single day interval
             case Day = "D"
+            /// Week interval
             case Week = "W"
+            /// Month interval
             case Month = "M"
+            /// Three month interval
             case Total = "3 M"
 
             var id: Self { self }
         }
 
-        var hourlyStats: [HourlyStats] = []
-        var glucoseRangeStats: [GlucoseRangeStats] = []
+        /// Defines the main categories of statistics available in the app
+        enum StatisticViewType: String, CaseIterable, Identifiable {
+            /// Glucose-related statistics including AGP and distributions
+            case glucose
+            /// Insulin delivery statistics including TDD and bolus distributions
+            case insulin
+            /// Loop performance and system status statistics
+            case looping
+            /// Meal-related statistics and correlations
+            case meals
+
+            var id: String { rawValue }
+
+            var title: String {
+                switch self {
+                case .glucose: return "Glucose"
+                case .insulin: return "Insulin"
+                case .looping: return "Looping"
+                case .meals: return "Meals"
+                }
+            }
+        }
 
         override func subscribe() {
-            setupGlucoseArray(for: .Today)
+            setupGlucoseArray()
             setupTDDs()
             setupBolusStats()
             setupLoopStatRecords()
@@ -75,38 +186,24 @@ extension Stat {
             timeInRangeChartStyle = settingsManager.settings.timeInRangeChartStyle
         }
 
-        func setupGlucoseArray(for duration: Duration) {
+        /// Initializes the glucose array and calculates initial statistics
+        func setupGlucoseArray() {
             Task {
-                let ids = await fetchGlucose(for: duration)
+                let ids = await fetchGlucose()
                 await updateGlucoseArray(with: ids)
-
-                // 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)
+                await precalculateStats(from: ids)
+                await updateDisplayedStats(for: selectedGlucoseChartType)
             }
         }
 
-        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
-            }
-
+        /// 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] {
             let results = await CoreDataStack.shared.fetchEntitiesAsync(
                 ofType: GlucoseStored.self,
                 onContext: context,
-                predicate: predicate,
+                predicate: NSPredicate.glucoseForStatsTotal,
                 key: "date",
                 ascending: false,
                 batchSize: 100,
@@ -115,16 +212,19 @@ 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(
@@ -132,22 +232,139 @@ 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 {
         private var workItem: DispatchWorkItem?
 
+        /// Schedules a delayed update action
+        /// - Parameter action: The closure to execute after the delay
+        /// Cancels any previously scheduled update before scheduling a new one
         func scheduleUpdate(action: @escaping () -> Void) {
             workItem?.cancel()
 
             let newWorkItem = DispatchWorkItem {
-                Task { @MainActor in
-                    action()
-                }
+                action()
             }
             workItem = newWorkItem
 
-            DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: newWorkItem)
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: newWorkItem)
         }
     }
 }

+ 17 - 57
FreeAPS/Sources/Modules/Stat/View/StatRootView.swift

@@ -16,54 +16,12 @@ extension Stat {
         @Environment(AppState.self) var appState
 
         @State var state = StateModel()
-        @State private var selectedView: StatisticViewType = .glucose
-        @State private var selectedGlucoseChartType: GlucoseChartType = .percentile
-        @State private var selectedInsulinChartType: InsulinChartType = .totalDailyDose
-        @State private var selectedLoopingChartType: LoopingChartType = .loopingPerformance
-        @State private var selectedMealChartType: MealChartType = .totalMeals
-
-        enum StatisticViewType: String, CaseIterable, Identifiable {
-            case glucose
-            case insulin
-            case looping
-            case meals
-
-            var id: String { rawValue }
-            var title: String {
-                switch self {
-                case .glucose: return "Glucose"
-                case .insulin: return "Insulin"
-                case .looping: return "Looping"
-                case .meals: return "Meals"
-                }
-            }
-        }
-
-        enum GlucoseChartType: String, CaseIterable {
-            case percentile = "Percentile"
-            case distribution = "Distribution"
-        }
-
-        enum InsulinChartType: String, CaseIterable {
-            case totalDailyDose = "Total Daily Dose"
-            case bolusDistribution = "Bolus Distribution"
-        }
-
-        enum LoopingChartType: String, CaseIterable {
-            case loopingPerformance = "Looping Performance"
-            case cgmConnectionTrace = "CGM Connection Trace"
-            case trioUpTime = "Trio Up-Time"
-        }
-
-        enum MealChartType: String, CaseIterable {
-            case totalMeals = "Total Meals"
-            case mealToHypoHyperDistribution = "Meal to Hypo/Hyper"
-        }
+        @State private var selectedView: StateModel.StatisticViewType = .glucose
 
         var body: some View {
             VStack {
                 Picker("View", selection: $selectedView) {
-                    ForEach(StatisticViewType.allCases) { viewType in
+                    ForEach(StateModel.StatisticViewType.allCases) { viewType in
                         Text(viewType.title).tag(viewType)
                     }
                 }
@@ -85,7 +43,6 @@ extension Stat {
                     }
                     .padding()
                 }
-//                .animation(.easeInOut, value: selectedView)
             }
             .background(appState.trioBackgroundColor(for: colorScheme))
             .onAppear(perform: configureView)
@@ -110,7 +67,7 @@ extension Stat {
 
                 Spacer()
 
-                Picker("Glucose Chart Type", selection: $selectedGlucoseChartType) {
+                Picker("Glucose Chart Type", selection: $state.selectedGlucoseChartType) {
                     ForEach(GlucoseChartType.allCases, id: \.self) { type in
                         Text(type.rawValue)
                     }
@@ -119,8 +76,8 @@ extension Stat {
             }.padding(.horizontal)
 
             Picker("Duration", selection: $state.selectedDurationForGlucoseStats) {
-                ForEach(StateModel.Duration.allCases, id: \.self) { duration in
-                    Text(duration.rawValue)
+                ForEach(StateModel.StatsTimeInterval.allCases, id: \.self) { timeInterval in
+                    Text(timeInterval.rawValue)
                 }
             }
             .pickerStyle(.segmented)
@@ -144,7 +101,7 @@ extension Stat {
 
                 Spacer()
 
-                Picker("Insulin Chart Type", selection: $selectedInsulinChartType) {
+                Picker("Insulin Chart Type", selection: $state.selectedInsulinChartType) {
                     ForEach(InsulinChartType.allCases, id: \.self) { type in
                         Text(type.rawValue)
                     }
@@ -159,7 +116,7 @@ extension Stat {
             .pickerStyle(.segmented)
 
             StatCard {
-                switch selectedInsulinChartType {
+                switch state.selectedInsulinChartType {
                 case .totalDailyDose:
                     if state.tddStats.isEmpty {
                         ContentUnavailableView(
@@ -207,18 +164,21 @@ extension Stat {
         private var timeInRangeCard: some View {
             StatCard {
                 VStack(spacing: Constants.spacing) {
-                    switch selectedGlucoseChartType {
+                    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,
-                            isToday: state.selectedDurationForGlucoseStats == .Today
+                            hourlyStats: state.hourlyStats
                         )
                     case .distribution:
                         GlucoseDistributionChart(
+                            selectedDuration: $state.selectedDurationForGlucoseStats,
+                            state: state,
                             glucose: state.glucoseFromPersistence,
                             highLimit: state.highLimit,
                             lowLimit: state.lowLimit,
@@ -272,7 +232,7 @@ extension Stat {
 
                 Spacer()
 
-                Picker("Looping Chart Type", selection: $selectedLoopingChartType) {
+                Picker("Looping Chart Type", selection: $state.selectedLoopingChartType) {
                     ForEach(LoopingChartType.allCases, id: \.self) { type in
                         Text(type.rawValue)
                     }
@@ -286,7 +246,7 @@ extension Stat {
             }
             .pickerStyle(.segmented)
 
-            switch selectedLoopingChartType {
+            switch state.selectedLoopingChartType {
             case .loopingPerformance:
                 if state.loopStatRecords.isEmpty {
                     ContentUnavailableView(
@@ -338,7 +298,7 @@ extension Stat {
 
                 Spacer()
 
-                Picker("Meal Chart Type", selection: $selectedMealChartType) {
+                Picker("Meal Chart Type", selection: $state.selectedMealChartType) {
                     ForEach(MealChartType.allCases, id: \.self) { type in
                         Text(type.rawValue)
                     }
@@ -353,7 +313,7 @@ extension Stat {
             .pickerStyle(.segmented)
 
             StatCard {
-                switch selectedMealChartType {
+                switch state.selectedMealChartType {
                 case .totalMeals:
                     var hasMealData: Bool {
                         state.mealStats.contains { $0.carbs > 0 || $0.fat > 0 || $0.protein > 0 }

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

@@ -2,16 +2,78 @@ 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: [GlucoseRangeStats]
+    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))"
+        }
+    }
 
     var body: some View {
         VStack(alignment: .leading, spacing: 8) {
-            Text("Glucose Distribution")
-                .font(.headline)
+            HStack(alignment: .top) {
+                Text("Glucose Distribution")
+                    .font(.headline)
+
+                Spacer()
+
+                Text(formatVisibleDateRange())
+                    .font(.subheadline)
+                    .foregroundStyle(.secondary)
+            }
 
             Chart(glucoseRangeStats) { range in
                 ForEach(range.values, id: \.hour) { value in
@@ -33,13 +95,7 @@ struct GlucoseDistributionChart: View {
                 ">220": .orange.opacity(0.8)
             ])
             .chartYAxis {
-                AxisMarks(position: .leading)
-            }
-            .chartYAxisLabel(alignment: .leading) {
-                Text("Percentage")
-                    .foregroundStyle(.primary)
-                    .font(.caption)
-                    .padding(.vertical, 3)
+                AxisMarks(position: .trailing)
             }
             .chartXAxis {
                 AxisMarks(values: .stride(by: .hour, count: 3)) { _ in
@@ -47,7 +103,21 @@ 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)
+        }
     }
 }

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

@@ -2,32 +2,111 @@ 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 selection: Date? = nil
+    @State private var scrollPosition = Date()
+    @State private var selection: Date?
+    @State private var isScrolling = false
+    @State private var updateTimer = Stat.UpdateTimer()
 
-    private var selectedStats: HourlyStats? {
-        guard let selection = selection else { return nil }
+    private func getDataRange() -> (start: Date, end: Date) {
+        let calendar = Calendar.current
 
-        // Don't show stats for future times if viewing today
-        if isToday && selection > Date() {
-            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
+            )
         }
+    }
 
+    private var visibleDateRange: (start: Date, end: Date) {
         let calendar = Calendar.current
-        let hour = calendar.component(.hour, from: selection)
-        return hourlyStats.first { Int($0.hour) == hour }
+
+        // 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))"
+        }
     }
 
     var body: some View {
         VStack(alignment: .leading, spacing: 8) {
-            Text("Ambulatory Glucose Profile (AGP)")
-                .font(.headline)
+            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)
+            }
 
             Chart {
                 // TODO: ensure data is still correct
@@ -37,7 +116,7 @@ struct GlucosePercentileChart: View {
                 // 10-90 percentile area
                 ForEach(hourlyStats, id: \.hour) { stats in
                     AreaMark(
-                        x: .value("Hour", Calendar.current.dateForChartHour(stats.hour)),
+                        x: .value("Hour", Calendar.current.dateForChartHour(stats.hour), unit: .hour),
                         yStart: .value("10th Percentile", stats.percentile10),
                         yEnd: .value("90th Percentile", stats.percentile90),
                         series: .value("10-90", "10-90")
@@ -48,7 +127,7 @@ struct GlucosePercentileChart: View {
                 // 25-75 percentile area
                 ForEach(hourlyStats, id: \.hour) { stats in
                     AreaMark(
-                        x: .value("Hour", Calendar.current.dateForChartHour(stats.hour)),
+                        x: .value("Hour", Calendar.current.dateForChartHour(stats.hour), unit: .hour),
                         yStart: .value("25th Percentile", stats.percentile25),
                         yEnd: .value("75th Percentile", stats.percentile75),
                         series: .value("25-75", "25-75")
@@ -59,7 +138,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)),
+                        x: .value("Hour", Calendar.current.dateForChartHour(stats.hour), unit: .hour),
                         y: .value("Median", stats.median),
                         series: .value("Median", "Median")
                     )
@@ -67,110 +146,104 @@ struct GlucosePercentileChart: View {
                     .foregroundStyle(.blue)
                 }
 
-                // 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
-                            )
-                        }
+                // 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
+                        )
+                    }
                 }
             }
             .chartYAxis {
-                AxisMarks(position: .leading)
-            }
-            .chartYAxisLabel(alignment: .leading) {
-                Text("\(units.rawValue)")
-                    .foregroundStyle(.primary)
-                    .font(.caption)
-                    .padding(.vertical, 3)
+                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()
+                    }
+                }
             }
             .chartXAxis {
                 AxisMarks(values: .stride(by: .hour, count: 3)) { _ in
-                    AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .narrow)), anchor: .top)
+                    AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .narrow)), centered: true, 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)
-
-            legend
+        }
+        // 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
         }
     }
 
-    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)
-                }
-
-                // 25-75 Percentile
-                HStack(spacing: 8) {
-                    Rectangle()
-                        .frame(width: 20, height: 8)
-                        .foregroundStyle(.blue.opacity(0.3))
-                    Text("25% - 75%")
-                        .font(.caption)
-                        .foregroundStyle(.secondary)
-                }
-            }
+    private var selectedStats: HourlyStats? {
+        guard let selection = selection else { return nil }
 
-            // Median
-            HStack(spacing: 8) {
-                Rectangle()
-                    .frame(width: 20, height: 2)
-                    .foregroundStyle(.blue)
-                Text("Median")
-                    .font(.caption)
-                    .foregroundStyle(.secondary)
-            }
+        // Don't show stats for future times if viewing today
+        if isToday && selection > Date() {
+            return nil
+        }
 
-            VStack {
-                // High Limit
-                HStack(spacing: 8) {
-                    Rectangle()
-                        .frame(width: 20, height: 1)
-                        .foregroundStyle(.orange)
-                    Text("High Limit")
-                        .font(.caption)
-                        .foregroundStyle(.secondary)
-                }
+        let calendar = Calendar.current
+        let hour = calendar.component(.hour, from: selection)
+        return hourlyStats.first { Int($0.hour) == hour }
+    }
 
-                // Low Limit
-                HStack(spacing: 8) {
-                    Rectangle()
-                        .frame(width: 20, height: 1)
-                        .foregroundStyle(.red)
-                    Text("Low Limit")
-                        .font(.caption)
-                        .foregroundStyle(.secondary)
-                }
-            }
-        }
-        .padding(.horizontal)
+    private var isToday: Bool {
+        let calendar = Calendar.current
+        let now = Date()
+        return calendar.isDate(now, inSameDayAs: calendar.startOfDay(for: now))
     }
 }