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

AGP selection popover; rewrite TDD chart code

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

+ 19 - 1
FreeAPS/Sources/Models/TDD.swift

@@ -1,6 +1,24 @@
 import Foundation
 import Foundation
 
 
-struct TDD: Codable, Equatable {
+struct TDD: Sendable, Codable, Equatable, Identifiable {
+    var id = UUID()
+
     let totalDailyDose: Decimal?
     let totalDailyDose: Decimal?
     let timestamp: Date?
     let timestamp: Date?
+
+    init(totalDailyDose: Decimal?, timestamp: Date?) {
+        self.totalDailyDose = totalDailyDose
+        self.timestamp = timestamp
+    }
+
+    init?(from dictionary: [String: Any]) {
+        guard let deliverAt = dictionary["deliverAt"] as? Date,
+              let totalDailyDose = dictionary["totalDailyDose"] as? Decimal
+        else {
+            return nil
+        }
+
+        self.totalDailyDose = totalDailyDose
+        timestamp = deliverAt
+    }
 }
 }

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

@@ -18,10 +18,10 @@ extension Stat.StateModel {
     /// Updates the bolus statistics for the currently selected time period
     /// Updates the bolus statistics for the currently selected time period
     func updateBolusStats() {
     func updateBolusStats() {
         Task {
         Task {
-            let stats = await fetchBolusStats(days: requestedDaysTDD, endDate: requestedEndDayTDD)
-            await MainActor.run {
-                self.bolusStats = stats
-            }
+//            let stats = await fetchBolusStats(days: requestedDaysTDD, endDate: requestedEndDayTDD)
+//            await MainActor.run {
+//                self.bolusStats = stats
+//            }
         }
         }
     }
     }
 
 

+ 18 - 176
FreeAPS/Sources/Modules/Stat/StatStateModel+Setup/TDDSetup.swift

@@ -1,139 +1,12 @@
 import CoreData
 import CoreData
 import Foundation
 import Foundation
 
 
-extension Decimal {
-    func rounded(scale: Int, roundingMode: NSDecimalNumber.RoundingMode) -> Decimal {
-        var result = Decimal()
-        var mutableSelf = self
-        NSDecimalRound(&result, &mutableSelf, scale, roundingMode)
-        return result
-    }
-}
-
 extension Stat.StateModel {
 extension Stat.StateModel {
-    /// Represents different time ranges for Total Daily Dose calculations
-    enum TDDTimeRange {
-        /// Today
-        case today
-        /// Yesterday
-        case yesterday
-        /// Custom range with specified number of days and end date
-        case customRange(days: Int, endDate: Date)
-
-        /// Calculates the start and end dates for the time range
-        var dateRange: (start: Date, end: Date) {
-            let calendar = Calendar.current
-            let now = Date()
-
-            switch self {
-            case .today:
-                let startOfToday = calendar.startOfDay(for: now)
-                return (startOfToday, now)
-
-            case .yesterday:
-                let startOfToday = calendar.startOfDay(for: now)
-                let startOfYesterday = calendar.date(byAdding: .day, value: -1, to: startOfToday)!
-                let endOfYesterday = calendar.date(byAdding: .second, value: -1, to: startOfToday)!
-                return (startOfYesterday, endOfYesterday)
-
-            case let .customRange(days, endDate):
-                let endOfDay = calendar.date(
-                    bySettingHour: 23,
-                    minute: 59,
-                    second: 59,
-                    of: endDate
-                )!
-                let startDate = calendar.date(byAdding: .day, value: -(days - 1), to: endOfDay)!
-                return (startDate, endOfDay)
-            }
-        }
-    }
-
-    /// Configuration for TDD display and calculations
-    struct TDDConfiguration {
-        /// Number of days to display in the TDD chart (default: 7)
-        var requestedDays: Int = 7
-        /// End date for the TDD chart, defaults to end of current day
-        var endDate: Date = Calendar.current.date(
-            bySettingHour: 23,
-            minute: 59,
-            second: 59,
-            of: Date()
-        ) ?? Date()
-    }
-
-    /// Result structure containing TDD calculations for a specific time range
-    struct TDDResult: Sendable {
-        /// Array of daily doses for the period
-        let dailyDoses: [TDD]
-        /// Average TDD for non-zero values
-        let average: Decimal
-        /// Time range for which the result was calculated
-        let period: TDDTimeRange
-
-        /// Total insulin dose for the period
-        var totalDose: Decimal {
-            dailyDoses.reduce(Decimal.zero) { $0 + ($1.totalDailyDose ?? 0) }
-        }
-    }
-
-    /// Updates all TDD values concurrently: today, yesterday, and custom range
-    /// This method fetches and processes TDD data for all time ranges in parallel
-    /// and updates the UI state with the results.
-    func updateTDDValues() async {
-        // Fetch all required TDD ranges
-        async let today = fetchTDDForRange(.today)
-        async let yesterday = fetchTDDForRange(.yesterday)
-        async let customRange = fetchTDDForRange(.customRange(
-            days: tddConfig.requestedDays,
-            endDate: tddConfig.endDate
-        ))
-
-        // Await all results
-        let (todayResult, yesterdayResult, customRangeResult) = await (
-            today, yesterday, customRange
-        )
-
-        // Update UI state
-        await MainActor.run {
-            currentTDD = todayResult.totalDose
-            ytdTDDValue = yesterdayResult.totalDose
-            averageTDD = customRangeResult.average
-            dailyTotalDoses = customRangeResult.dailyDoses
-        }
-    }
-
-    /// Fetches and processes TDD data for a specific time range
-    /// - Parameter range: The time range for which to fetch TDD data
-    /// - Returns: A TDDResult containing processed TDD data for the specified range
-    private func fetchTDDForRange(_ range: TDDTimeRange) async -> TDDResult {
-        let dateRange = range.dateRange
-
-        let determinationIDs = await fetchDeterminations(
-            from: dateRange.start,
-            to: dateRange.end
-        )
-
-        let doses = await processDeterminations(determinationIDs, in: dateRange)
-        let average = calculateAverage(from: doses)
-
-        return TDDResult(
-            dailyDoses: doses,
-            average: average,
-            period: range
-        )
-    }
-
-    /// Fetches determination object IDs from Core Data for a given date range
-    /// - Parameters:
-    ///   - startDate: Start date of the range
-    ///   - endDate: End date of the range
-    /// - Returns: Array of NSManagedObjectIDs for matching determinations
-    private func fetchDeterminations(from startDate: Date, to endDate: Date) async -> [NSManagedObjectID] {
+    func fetchAndMapDeterminations() async -> [TDD] {
         let results = await CoreDataStack.shared.fetchEntitiesAsync(
         let results = await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
             ofType: OrefDetermination.self,
             onContext: determinationFetchContext,
             onContext: determinationFetchContext,
-            predicate: NSPredicate.determinationPeriod(from: startDate, to: endDate),
+            predicate: NSPredicate.determinationsForStats,
             key: "deliverAt",
             key: "deliverAt",
             ascending: false,
             ascending: false,
             propertiesToFetch: ["objectID", "timestamp", "deliverAt", "totalDailyDose"]
             propertiesToFetch: ["objectID", "timestamp", "deliverAt", "totalDailyDose"]
@@ -141,61 +14,30 @@ extension Stat.StateModel {
 
 
         return await determinationFetchContext.perform {
         return await determinationFetchContext.perform {
             guard let fetchedResults = results as? [[String: Any]] else { return [] }
             guard let fetchedResults = results as? [[String: Any]] else { return [] }
-            return fetchedResults.compactMap { $0["objectID"] as? NSManagedObjectID }
-        }
-    }
 
 
-    /// Processes determination objects into TDD records
-    /// - Parameters:
-    ///   - determinationIDs: Array of determination object IDs to process
-    ///   - dateRange: Date range for context (unused but kept for future use)
-    /// - Returns: Array of processed TDD records, sorted by date descending
-    private func processDeterminations(
-        _ determinationIDs: [NSManagedObjectID],
-        in _: (start: Date, end: Date)
-    ) async -> [TDD] {
-        await determinationFetchContext.perform {
+            // Group determinations by day
             let calendar = Calendar.current
             let calendar = Calendar.current
-
-            // Convert IDs to OrefDetermination objects
-            let determinations = determinationIDs.compactMap { id -> OrefDetermination? in
-                do {
-                    return try self.determinationFetchContext.existingObject(with: id) as? OrefDetermination
-                } catch {
-                    debugPrint("Error fetching determination: \(error)")
-                    return nil
-                }
+            let groupedByDay = Dictionary(grouping: fetchedResults) { result -> Date in
+                guard let deliverAt = result["deliverAt"] as? Date else { return Date() }
+                return calendar.startOfDay(for: deliverAt)
             }
             }
 
 
-            // Group by day
-            let groupedByDay = Dictionary(grouping: determinations) { determination in
-                calendar.startOfDay(for: determination.timestamp ?? determination.deliverAt ?? Date())
-            }
+            // Calculate total daily doses for each day
+            return groupedByDay.map { date, determinations -> TDD in
+                let totalDose = determinations.reduce(Decimal.zero) { sum, determination in
+                    sum + (determination["totalDailyDose"] as? Decimal ?? 0)
+                }
 
 
-            // Get latest determination for each day
-            return groupedByDay.compactMap { _, dayDeterminations in
-                guard let latestDetermination = dayDeterminations.max(by: {
-                    ($0.timestamp ?? $0.deliverAt ?? Date()) < ($1.timestamp ?? $1.deliverAt ?? Date())
-                }),
-                    let dose = latestDetermination.totalDailyDose as? Decimal
-                else { return nil }
+                // Calculate average dose for the day
+                let count = Decimal(determinations.count)
+                let averageDose = count > 0 ? totalDose / count : 0
 
 
                 return TDD(
                 return TDD(
-                    totalDailyDose: dose,
-                    timestamp: latestDetermination.deliverAt
+                    totalDailyDose: averageDose,
+                    timestamp: date
                 )
                 )
-            }.sorted { ($0.timestamp ?? Date()) > ($1.timestamp ?? Date()) }
+            }
+            .sorted { ($0.timestamp ?? Date()) > ($1.timestamp ?? Date()) }
         }
         }
     }
     }
-
-    /// Calculates the average TDD from an array of TDD records
-    /// - Parameter tdds: Array of TDD records to average
-    /// - Returns: Average TDD rounded to 1 decimal place, or 0 if no records
-    private func calculateAverage(from tdds: [TDD]) -> Decimal {
-        let totalSum = tdds.reduce(Decimal.zero) { $0 + ($1.totalDailyDose ?? 0) }
-        let count = Decimal(tdds.count)
-
-        guard count > 0 else { return 0 }
-        return (totalSum / count).rounded(scale: 1, roundingMode: .plain)
-    }
 }
 }

+ 61 - 53
FreeAPS/Sources/Modules/Stat/StatStateModel.swift

@@ -16,13 +16,15 @@ extension Stat {
         var loopStatRecords: [LoopStatRecord] = []
         var loopStatRecords: [LoopStatRecord] = []
         var groupedLoopStats: [LoopStatsByPeriod] = []
         var groupedLoopStats: [LoopStatsByPeriod] = []
         var mealStats: [MealStats] = []
         var mealStats: [MealStats] = []
-
-        var selectedDuration: Duration = .Today {
+        var tddStats: [TDD] = []
+        var selectedDurationForGlucoseStats: Duration = .Today {
             didSet {
             didSet {
-                setupGlucoseArray(for: selectedDuration)
+                setupGlucoseArray(for: selectedDurationForGlucoseStats)
             }
             }
         }
         }
 
 
+        var selectedDurationForInsulinStats: StatsTimeInterval = .Day
+
         var selectedDurationForLoopStats: Duration = .Today {
         var selectedDurationForLoopStats: Duration = .Today {
             didSet {
             didSet {
                 setupLoopStatRecords()
                 setupLoopStatRecords()
@@ -35,54 +37,6 @@ extension Stat {
             }
             }
         }
         }
 
 
-        /// TDD-related properties
-
-        /// Total insulin dose for the last 24 hours
-        var currentTDD: Decimal = 0
-
-        /// Total insulin dose for yesterday (previous calendar day)
-        var ytdTDDValue: Decimal = 0
-
-        /// Average TDD for the selected time period
-        var averageTDD: Decimal = 0
-
-        /// Array of daily total doses for the selected period
-        var dailyTotalDoses: [TDD] = []
-
-        /// Configuration for TDD display and calculations
-        private(set) var tddConfig = TDDConfiguration() {
-            didSet {
-                if oldValue.requestedDays != tddConfig.requestedDays ||
-                    oldValue.endDate != tddConfig.endDate
-                {
-                    Task {
-                        await updateTDDValues()
-                    }
-                }
-            }
-        }
-
-        /// Number of days to display in the TDD chart
-        var requestedDaysTDD: Int {
-            get { tddConfig.requestedDays }
-            set { tddConfig.requestedDays = newValue }
-        }
-
-        /// End date for the TDD chart
-        var requestedEndDayTDD: Date {
-            get { tddConfig.endDate }
-            set {
-                if let adjustedDate = Calendar.current.date(
-                    bySettingHour: 23,
-                    minute: 59,
-                    second: 59,
-                    of: newValue
-                ) {
-                    tddConfig.endDate = adjustedDate
-                }
-            }
-        }
-
         let context = CoreDataStack.shared.newTaskContext()
         let context = CoreDataStack.shared.newTaskContext()
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext
         let determinationFetchContext = CoreDataStack.shared.newTaskContext()
         let determinationFetchContext = CoreDataStack.shared.newTaskContext()
@@ -95,7 +49,16 @@ extension Stat {
             case Day = "D"
             case Day = "D"
             case Week = "W"
             case Week = "W"
             case Month = "M"
             case Month = "M"
-            case Total = "3 M."
+            case Total = "3 M"
+
+            var id: Self { self }
+        }
+
+        enum StatsTimeInterval: String, CaseIterable, Identifiable {
+            case Day = "D"
+            case Week = "W"
+            case Month = "M"
+            case Total = "3 M"
 
 
             var id: Self { self }
             var id: Self { self }
         }
         }
@@ -132,7 +95,10 @@ extension Stat {
 
 
         func setupTDDs() {
         func setupTDDs() {
             Task {
             Task {
-                await updateTDDValues()
+                let tddStats = await fetchAndMapDeterminations()
+                await MainActor.run {
+                    self.tddStats = tddStats
+                }
             }
             }
         }
         }
 
 
@@ -181,5 +147,47 @@ extension Stat {
                 )
                 )
             }
             }
         }
         }
+
+        var averageTDD: Decimal {
+            let calendar = Calendar.current
+            let now = Date()
+
+            // Filter TDDs based on selected time frame
+            let filteredTDDs: [TDD] = tddStats.filter { tdd in
+                guard let timestamp = tdd.timestamp else { return false }
+
+                switch selectedDurationForInsulinStats {
+                case .Day:
+                    // Last 3 days
+                    let threeDaysAgo = calendar.date(byAdding: .day, value: -3, to: now)!
+                    return timestamp >= threeDaysAgo
+                case .Week:
+                    // Last week
+                    let weekAgo = calendar.date(byAdding: .weekOfYear, value: -1, to: now)!
+                    return timestamp >= weekAgo
+                case .Month:
+                    // Last month
+                    let monthAgo = calendar.date(byAdding: .month, value: -1, to: now)!
+                    return timestamp >= monthAgo
+                case .Total:
+                    // Last 3 months
+                    let threeMonthsAgo = calendar.date(byAdding: .month, value: -3, to: now)!
+                    return timestamp >= threeMonthsAgo
+                }
+            }
+
+            let sum = filteredTDDs.reduce(Decimal.zero) { $0 + ($1.totalDailyDose ?? 0) }
+            return filteredTDDs.isEmpty ? 0 : sum / Decimal(filteredTDDs.count)
+        }
+
+        func calculateAverageTDD(from startDate: Date, to endDate: Date) -> Decimal {
+            let filteredTDDs = tddStats.filter { tdd in
+                guard let timestamp = tdd.timestamp else { return false }
+                return timestamp >= startDate && timestamp <= endDate
+            }
+
+            let sum = filteredTDDs.reduce(Decimal.zero) { $0 + ($1.totalDailyDose ?? 0) }
+            return filteredTDDs.isEmpty ? 0 : sum / Decimal(filteredTDDs.count)
+        }
     }
     }
 }
 }

+ 14 - 25
FreeAPS/Sources/Modules/Stat/View/StatRootView.swift

@@ -85,7 +85,7 @@ extension Stat {
                     }
                     }
                     .padding()
                     .padding()
                 }
                 }
-                .animation(.easeInOut, value: selectedView)
+//                .animation(.easeInOut, value: selectedView)
             }
             }
             .background(appState.trioBackgroundColor(for: colorScheme))
             .background(appState.trioBackgroundColor(for: colorScheme))
             .onAppear(perform: configureView)
             .onAppear(perform: configureView)
@@ -118,7 +118,7 @@ extension Stat {
                 .pickerStyle(.menu)
                 .pickerStyle(.menu)
             }.padding(.horizontal)
             }.padding(.horizontal)
 
 
-            Picker("Duration", selection: $state.selectedDuration) {
+            Picker("Duration", selection: $state.selectedDurationForGlucoseStats) {
                 ForEach(StateModel.Duration.allCases, id: \.self) { duration in
                 ForEach(StateModel.Duration.allCases, id: \.self) { duration in
                     Text(duration.rawValue)
                     Text(duration.rawValue)
                 }
                 }
@@ -151,17 +151,16 @@ extension Stat {
                 }.pickerStyle(.menu)
                 }.pickerStyle(.menu)
             }.padding(.horizontal)
             }.padding(.horizontal)
 
 
-            Picker("Duration", selection: $state.selectedDuration) {
-                ForEach(StateModel.Duration.allCases, id: \.self) { duration in
-                    Text(duration.rawValue)
+            Picker("Duration", selection: $state.selectedDurationForInsulinStats) {
+                ForEach(StateModel.StatsTimeInterval.allCases) { timeInterval in
+                    Text(timeInterval.rawValue).tag(timeInterval)
                 }
                 }
             }
             }
             .pickerStyle(.segmented)
             .pickerStyle(.segmented)
 
 
-            // TODO: rework TDDChartView and BolusView to respect selectedDays from here and omit datepicker
             switch selectedInsulinChartType {
             switch selectedInsulinChartType {
             case .totalDailyDose:
             case .totalDailyDose:
-                if state.dailyTotalDoses.isEmpty || state.currentTDD == 0 {
+                if state.tddStats.isEmpty {
                     ContentUnavailableView(
                     ContentUnavailableView(
                         "No TDD Data",
                         "No TDD Data",
                         systemImage: "chart.bar.xaxis",
                         systemImage: "chart.bar.xaxis",
@@ -169,19 +168,12 @@ extension Stat {
                     )
                     )
                 } else {
                 } else {
                     TDDChartView(
                     TDDChartView(
-                        state: state,
-                        selectedDays: $state.requestedDaysTDD,
-                        selectedEndDate: $state.requestedEndDayTDD,
-                        dailyTotalDoses: $state.dailyTotalDoses,
-                        averageTDD: state.averageTDD,
-                        ytdTDD: state.ytdTDDValue
+                        selectedDuration: state.selectedDurationForInsulinStats,
+                        tddStats: state.tddStats,
+                        calculateAverage: { start, end in
+                            state.calculateAverageTDD(from: start, to: end)
+                        }
                     )
                     )
-                    .onChange(of: state.requestedDaysTDD) {
-                        state.updateBolusStats()
-                    }
-                    .onChange(of: state.requestedEndDayTDD) {
-                        state.updateBolusStats()
-                    }
                 }
                 }
 
 
             case .bolusDistribution:
             case .bolusDistribution:
@@ -197,9 +189,7 @@ extension Stat {
                     )
                     )
                 } else {
                 } else {
                     BolusStatsView(
                     BolusStatsView(
-                        bolusStats: state.bolusStats,
-                        selectedDays: $state.requestedDaysTDD,
-                        selectedEndDate: $state.requestedEndDayTDD
+                        bolusStats: state.bolusStats
                     )
                     )
                 }
                 }
             }
             }
@@ -214,16 +204,15 @@ extension Stat {
                             glucose: state.glucoseFromPersistence,
                             glucose: state.glucoseFromPersistence,
                             highLimit: state.highLimit,
                             highLimit: state.highLimit,
                             lowLimit: state.lowLimit,
                             lowLimit: state.lowLimit,
-                            isTodayOrLast24h: state.selectedDuration == .Today || state.selectedDuration == .Day,
                             units: state.units,
                             units: state.units,
-                            hourlyStats: state.hourlyStats
+                            hourlyStats: state.hourlyStats,
+                            isToday: state.selectedDurationForGlucoseStats == .Today
                         )
                         )
                     case .distribution:
                     case .distribution:
                         GlucoseDistributionChart(
                         GlucoseDistributionChart(
                             glucose: state.glucoseFromPersistence,
                             glucose: state.glucoseFromPersistence,
                             highLimit: state.highLimit,
                             highLimit: state.highLimit,
                             lowLimit: state.lowLimit,
                             lowLimit: state.lowLimit,
-                            isToday: state.selectedDuration == .Today || state.selectedDuration == .Day,
                             units: state.units,
                             units: state.units,
                             glucoseRangeStats: state.glucoseRangeStats
                             glucoseRangeStats: state.glucoseRangeStats
                         )
                         )

+ 5 - 7
FreeAPS/Sources/Modules/Stat/View/ViewElements/BolusStatsView.swift

@@ -3,8 +3,6 @@ import SwiftUI
 
 
 struct BolusStatsView: View {
 struct BolusStatsView: View {
     let bolusStats: [BolusStats]
     let bolusStats: [BolusStats]
-    @Binding var selectedDays: Int
-    @Binding var selectedEndDate: Date
 
 
     var body: some View {
     var body: some View {
         StatCard {
         StatCard {
@@ -61,11 +59,11 @@ struct BolusStatsView: View {
         AxisMarks { value in
         AxisMarks { value in
             if let date = value.as(Date.self) {
             if let date = value.as(Date.self) {
                 AxisValueLabel {
                 AxisValueLabel {
-                    if selectedDays < 8 {
-                        Text(date, format: .dateTime.weekday(.abbreviated))
-                    } else {
-                        Text(date, format: .dateTime.day().month(.defaultDigits))
-                    }
+//                    if selectedDays < 8 {
+//                        Text(date, format: .dateTime.weekday(.abbreviated))
+//                    } else {
+//                        Text(date, format: .dateTime.day().month(.defaultDigits))
+//                    }
                 }
                 }
                 AxisGridLine()
                 AxisGridLine()
             }
             }

+ 0 - 1
FreeAPS/Sources/Modules/Stat/View/ViewElements/GlucoseDistributionChart.swift

@@ -5,7 +5,6 @@ struct GlucoseDistributionChart: View {
     let glucose: [GlucoseStored]
     let glucose: [GlucoseStored]
     let highLimit: Decimal
     let highLimit: Decimal
     let lowLimit: Decimal
     let lowLimit: Decimal
-    let isToday: Bool
     let units: GlucoseUnits
     let units: GlucoseUnits
     let glucoseRangeStats: [GlucoseRangeStats]
     let glucoseRangeStats: [GlucoseRangeStats]
 
 

+ 91 - 18
FreeAPS/Sources/Modules/Stat/View/ViewElements/GlucosePercentileChart.swift

@@ -5,9 +5,24 @@ struct GlucosePercentileChart: View {
     let glucose: [GlucoseStored]
     let glucose: [GlucoseStored]
     let highLimit: Decimal
     let highLimit: Decimal
     let lowLimit: Decimal
     let lowLimit: Decimal
-    let isTodayOrLast24h: Bool
     let units: GlucoseUnits
     let units: GlucoseUnits
     let hourlyStats: [HourlyStats]
     let hourlyStats: [HourlyStats]
+    let isToday: Bool
+
+    @State private var selection: Date? = nil
+
+    private var selectedStats: HourlyStats? {
+        guard let selection = selection else { return nil }
+
+        // Don't show stats for future times if viewing today
+        if isToday && selection > Date() {
+            return nil
+        }
+
+        let calendar = Calendar.current
+        let hour = calendar.component(.hour, from: selection)
+        return hourlyStats.first { Int($0.hour) == hour }
+    }
 
 
     var body: some View {
     var body: some View {
         VStack(alignment: .leading, spacing: 8) {
         VStack(alignment: .leading, spacing: 8) {
@@ -15,18 +30,6 @@ struct GlucosePercentileChart: View {
                 .font(.headline)
                 .font(.headline)
 
 
             Chart {
             Chart {
-//                if isTodayOrLast24h {
-//                    // Single day line chart
-//                    ForEach(glucose.sorted(by: { ($0.date ?? Date()) < ($1.date ?? Date()) }), id: \.id) { reading in
-//                        LineMark(
-//                            x: .value("Time", reading.date ?? Date()),
-//                            y: .value("Glucose", Double(reading.glucose))
-//                        )
-//                        .lineStyle(StrokeStyle(lineWidth: 2))
-//                        .foregroundStyle(.blue)
-//                    }
-//                } else {
-
                 // TODO: ensure data is still correct
                 // TODO: ensure data is still correct
                 // TODO: ensure area marks and line mark take color of respective range
                 // TODO: ensure area marks and line mark take color of respective range
 
 
@@ -63,7 +66,6 @@ struct GlucosePercentileChart: View {
                     .lineStyle(StrokeStyle(lineWidth: 2))
                     .lineStyle(StrokeStyle(lineWidth: 2))
                     .foregroundStyle(.blue)
                     .foregroundStyle(.blue)
                 }
                 }
-//                }
 
 
                 // High/Low limit lines
                 // High/Low limit lines
                 RuleMark(y: .value("High Limit", Double(highLimit)))
                 RuleMark(y: .value("High Limit", Double(highLimit)))
@@ -73,8 +75,23 @@ struct GlucosePercentileChart: View {
                 RuleMark(y: .value("Low Limit", Double(lowLimit)))
                 RuleMark(y: .value("Low Limit", Double(lowLimit)))
                     .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
                     .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
                     .foregroundStyle(.red)
                     .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
+                            )
+                        }
+                }
             }
             }
-//            .chartYScale(domain: 40 ... 400)
             .chartYAxis {
             .chartYAxis {
                 AxisMarks(position: .leading)
                 AxisMarks(position: .leading)
             }
             }
@@ -90,12 +107,10 @@ struct GlucosePercentileChart: View {
                     AxisGridLine()
                     AxisGridLine()
                 }
                 }
             }
             }
+            .chartXSelection(value: $selection)
             .frame(height: 200)
             .frame(height: 200)
 
 
-            // Legend
-//            if !isTodayOrLast24h {
             legend
             legend
-//            }
         }
         }
     }
     }
 
 
@@ -159,6 +174,64 @@ struct GlucosePercentileChart: View {
     }
     }
 }
 }
 
 
+struct AGPSelectionPopover: View {
+    let stats: HourlyStats
+    let time: Date
+    let units: GlucoseUnits
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 4) {
+            HStack {
+                Image(systemName: "clock")
+                Text(time.formatted(.dateTime.hour().minute(.twoDigits)))
+                    .font(.body).bold()
+            }
+            .font(.caption)
+            .foregroundStyle(.secondary)
+
+            Grid(alignment: .leading, horizontalSpacing: 8) {
+                GridRow {
+                    Text("90%:")
+                    Text(stats.percentile90.formatted(.number))
+                    Text(units.rawValue)
+                        .foregroundStyle(.secondary)
+                }
+                GridRow {
+                    Text("75%:")
+                    Text(stats.percentile75.formatted(.number))
+                    Text(units.rawValue)
+                        .foregroundStyle(.secondary)
+                }
+                GridRow {
+                    Text("Median:")
+                    Text(stats.median.formatted(.number))
+                    Text(units.rawValue)
+                        .foregroundStyle(.secondary)
+                }
+                GridRow {
+                    Text("25%:")
+                    Text(stats.percentile25.formatted(.number))
+                    Text(units.rawValue)
+                        .foregroundStyle(.secondary)
+                }
+                GridRow {
+                    Text("10%:")
+                    Text(stats.percentile10.formatted(.number))
+                    Text(units.rawValue)
+                        .foregroundStyle(.secondary)
+                }
+            }
+            .font(.caption)
+        }
+        .padding(8)
+        .background {
+            RoundedRectangle(cornerRadius: 8)
+                .fill(.background)
+                .shadow(radius: 2)
+        }
+    }
+}
+
 private extension Calendar {
 private extension Calendar {
     func startOfHour(for date: Date) -> Date {
     func startOfHour(for date: Date) -> Date {
         let components = dateComponents([.year, .month, .day, .hour], from: date)
         let components = dateComponents([.year, .month, .day, .hour], from: date)

+ 157 - 172
FreeAPS/Sources/Modules/Stat/View/ViewElements/TDDChart.swift

@@ -2,214 +2,199 @@ import Charts
 import SwiftUI
 import SwiftUI
 
 
 struct TDDChartView: View {
 struct TDDChartView: View {
-    private enum Constants {
-        static let dayOptions = [3, 5, 7, 10, 14, 21, 28]
-        static let chartHeight: CGFloat = 200
-        static let spacing: CGFloat = 8
-        static let cornerRadius: CGFloat = 10
-        static let summaryBackgroundOpacity = 0.1
+    let selectedDuration: Stat.StateModel.StatsTimeInterval
+    let tddStats: [TDD]
+    let calculateAverage: (Date, Date) -> Decimal
+
+    @State private var scrollPosition = Date()
+    @State private var currentAverageTDD: Decimal = 0
+    @State private var selectedDate: Date?
+
+    private var visibleDomainLength: TimeInterval {
+        switch selectedDuration {
+        case .Day: return 3 * 24 * 3600 // 3 days
+        case .Week: return 7 * 24 * 3600 // 1 week
+        case .Month: return 30 * 24 * 3600 // 1 month
+        case .Total: return 90 * 24 * 3600 // 3 months
+        }
     }
     }
 
 
-    let state: Stat.StateModel
-    @Binding var selectedDays: Int
-    @Binding var selectedEndDate: Date
-    @Binding var dailyTotalDoses: [TDD]
-    var averageTDD: Decimal
-    var ytdTDD: Decimal
+    private var scrollTargetDuration: TimeInterval {
+        switch selectedDuration {
+        case .Day: return 3 * 24 * 3600 // Scroll by 3 days
+        case .Week: return 7 * 24 * 3600 // Scroll by 1 week
+        case .Month: return 30 * 24 * 3600 // Scroll by 1 month
+        case .Total: return 90 * 24 * 3600 // Scroll by 3 months
+        }
+    }
 
 
-    @Environment(\.colorScheme) var colorScheme
+    private var strideInterval: Calendar.Component {
+        .day
+    }
 
 
-    var body: some View {
-        VStack(spacing: Constants.spacing) {
-            dateSelectionView
-            summaryCardView
-            chartCard
+    private var dateFormat: Date.FormatStyle {
+        switch selectedDuration {
+        case .Day:
+            return .dateTime.weekday(.abbreviated)
+        case .Week:
+            return .dateTime.weekday(.abbreviated)
+        case .Month:
+            return .dateTime.day()
+        case .Total:
+            return .dateTime.month(.abbreviated)
         }
         }
     }
     }
 
 
-    // MARK: - Views
-
-    private var dateSelectionView: some View {
-        HStack {
-            Text("Time Frame")
-                .font(.subheadline)
-                .foregroundStyle(.secondary)
+    private var alignmentComponents: DateComponents {
+        switch selectedDuration {
+        case .Day:
+            return DateComponents(hour: 0)
+        case .Week:
+            return DateComponents(weekday: 1)
+        case .Month:
+            return DateComponents(day: 1)
+        case .Total:
+            return DateComponents(day: 1, hour: 0)
+        }
+    }
 
 
-            Spacer()
+    private var visibleDateRange: (start: Date, end: Date) {
+        let halfDomain = visibleDomainLength / 2
+        let start = scrollPosition.addingTimeInterval(-halfDomain)
+        let end = scrollPosition.addingTimeInterval(halfDomain)
+        return (start, end)
+    }
 
 
-            CustomDatePicker(selection: $selectedEndDate)
-                .frame(height: 30)
+    private func updateAverage() {
+        let (start, end) = visibleDateRange
+        currentAverageTDD = calculateAverage(start, end)
+    }
 
 
-            Picker("Days", selection: $selectedDays) {
-                ForEach(Constants.dayOptions, id: \.self) { days in
-                    Text("\(days) days").tag(days)
-                }
-            }
-            .pickerStyle(.segmented)
+    private func getTDDForDate(_ date: Date) -> TDD? {
+        tddStats.first { tdd in
+            guard let timestamp = tdd.timestamp else { return false }
+            return Calendar.current.isDate(timestamp, inSameDayAs: date)
         }
         }
     }
     }
 
 
-    private var summaryCardView: some View {
-        VStack(spacing: Constants.spacing) {
-            tddRow(
-                title: "Today",
-                value: state.currentTDD
-            )
-            Divider()
-            tddRow(
-                title: "Yesterday",
-                value: ytdTDD
-            )
-            Divider()
-            tddRow(
-                title: "Average \(selectedDays) days",
-                value: averageTDD
-            )
-        }
-        .padding()
-        .background(
-            RoundedRectangle(cornerRadius: Constants.cornerRadius)
-                .fill(Color.secondary.opacity(Constants.summaryBackgroundOpacity))
-        )
+    var body: some View {
+        chartCard
+            .onChange(of: scrollPosition) {
+                updateAverage()
+            }
+            .onAppear {
+                updateAverage()
+            }
     }
     }
 
 
+    // MARK: - Views
+
     private var chartCard: some View {
     private var chartCard: some View {
-        VStack(alignment: .leading, spacing: Constants.spacing) {
-            Text("Total Daily Doses")
-                .font(.headline)
+        VStack(alignment: .leading, spacing: 8) {
+            VStack(alignment: .leading, spacing: 6) {
+                Text("Total Daily Doses")
+                    .font(.headline)
+
+                VStack(alignment: .leading, spacing: 4) {
+                    Text("Average: \(currentAverageTDD.formatted(.number.precision(.fractionLength(1)))) U")
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+
+                    Text(
+                        "\(visibleDateRange.start.formatted(.dateTime.month().day())) - \(visibleDateRange.end.formatted(.dateTime.month().day()))"
+                    )
+                    .font(.subheadline)
+                    .foregroundStyle(.secondary)
+                }
+            }
 
 
             Chart {
             Chart {
-                ForEach(chartData, id: \.date) { entry in
+                ForEach(tddStats) { entry in
                     BarMark(
                     BarMark(
-                        x: .value("Date", entry.date, unit: .day),
-                        y: .value("Insulin", entry.dose)
+                        x: .value("Date", entry.timestamp ?? Date(), unit: strideInterval),
+                        y: .value("Insulin", entry.totalDailyDose ?? 0)
                     )
                     )
                     .foregroundStyle(Color.insulin.gradient)
                     .foregroundStyle(Color.insulin.gradient)
-                    .annotation(position: .top) {
-                        if entry.dose > 0 {
-                            Text(formatDose(entry.dose))
-                                .font(.caption2)
-                                .foregroundStyle(.primary)
-                        }
-                    }
                 }
                 }
 
 
-                if let average = calculateAverage() {
-                    RuleMark(y: .value("Average", average))
-                        .foregroundStyle(.primary)
-                        .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
-                        .annotation(position: .automatic) {
-                            Text("\(formatDose(average)) U")
-                                .font(.caption)
-                                .foregroundStyle(Color.insulin)
-                        }
+                if let selectedDate,
+                   let selectedTDD = getTDDForDate(selectedDate)
+                {
+                    RuleMark(
+                        x: .value("Selected Date", selectedDate)
+                    )
+                    .foregroundStyle(.secondary.opacity(0.3))
+                    .annotation(
+                        position: .top,
+                        spacing: 0,
+                        overflowResolution: .init(x: .fit, y: .disabled)
+                    ) {
+                        TDDSelectionPopover(date: selectedDate, tdd: selectedTDD)
+                    }
                 }
                 }
             }
             }
-            .chartXAxis {
-                tddChartXAxisMarks
-            }
             .chartYAxis {
             .chartYAxis {
                 AxisMarks { _ in
                 AxisMarks { _ in
                     AxisValueLabel()
                     AxisValueLabel()
                     AxisGridLine()
                     AxisGridLine()
                 }
                 }
             }
             }
-            .chartYAxisLabel(alignment: .trailing) {
-                Text("Units (U)")
-                    .foregroundColor(.primary)
-            }
-            .chartYScale(domain: 0 ... calculateYAxisMaximum())
-        }
-        .frame(height: 200)
-        .padding()
-        .background(
-            RoundedRectangle(cornerRadius: Constants.cornerRadius)
-                .fill(Color.secondary.opacity(Constants.summaryBackgroundOpacity))
-        )
-    }
-
-    // MARK: - Helper Views
-
-    private var tddChartXAxisMarks: some AxisContent {
-        AxisMarks(values: .stride(by: .day)) { value in
-            if let date = value.as(Date.self),
-               xAxisLabelValues().contains(where: { $0.date == date })
-            {
-                AxisValueLabel(xAxisLabelValues().first { $0.date == date }?.label ?? "")
-            }
-            AxisGridLine()
-        }
-    }
-
-    private func tddRow(title: String, value: Decimal) -> some View {
-        HStack {
-            Text(title)
-                .foregroundStyle(.secondary)
-            Spacer()
-            Text(formatDose(value))
-                .foregroundColor(.primary)
-            Text("U")
-                .foregroundStyle(.secondary)
-        }
-        .font(.subheadline)
-    }
-
-    // MARK: - Data Processing
-
-    private var chartData: [(date: Date, dose: Decimal)] {
-        completeData(forDays: selectedDays)
-    }
-
-    private func calculateAverage() -> Decimal? {
-        let nonZeroDoses = chartData.map(\.dose).filter { $0 > 0 }
-        guard !nonZeroDoses.isEmpty else { return nil }
-        return nonZeroDoses.reduce(0, +) / Decimal(nonZeroDoses.count)
-    }
-
-    private func calculateYAxisMaximum() -> Double {
-        let maxDose = chartData.map(\.dose).max() ?? 0
-        let average = calculateAverage() ?? 0
-        return (max(maxDose, average) * 1.2).doubleValue // Add 20% padding
-    }
-
-    private func formatDose(_ value: Decimal) -> String {
-        Formatter.decimalFormatterWithOneFractionDigit.string(from: value as NSNumber) ?? "0"
-    }
-
-    private func completeData(forDays days: Int) -> [(date: Date, dose: Decimal)] {
-        var completeData: [(date: Date, dose: Decimal)] = []
-        let calendar = Calendar.current
-        var currentDate = calendar.startOfDay(for: selectedEndDate)
-
-        for _ in 0 ..< days {
-            if let existingEntry = dailyTotalDoses.first(where: { entry in
-                guard let timestamp = entry.timestamp else { return false }
-                return calendar.isDate(timestamp, inSameDayAs: currentDate)
-            }) {
-                completeData.append((date: currentDate, dose: existingEntry.totalDailyDose ?? 0))
-            } else {
-                completeData.append((date: currentDate, dose: 0))
+            .chartXAxis {
+                AxisMarks(preset: .aligned, values: .stride(by: strideInterval)) { value in
+                    if let date = value.as(Date.self) {
+                        let day = Calendar.current.component(.day, from: date)
+
+                        switch selectedDuration {
+                        case .Month:
+                            if day % 5 == 0 { // Only show every 5th day
+                                AxisValueLabel(format: dateFormat)
+                                AxisGridLine()
+                            }
+                        case .Total:
+                            // Only show January, April, July, October
+                            if day == 1 && Calendar.current.component(.month, from: date) % 3 == 1 {
+                                AxisValueLabel(format: dateFormat)
+                                AxisGridLine()
+                            }
+                        default:
+                            AxisValueLabel(format: dateFormat)
+                            AxisGridLine()
+                        }
+                    }
+                }
             }
             }
-            currentDate = calendar.date(byAdding: .day, value: -1, to: currentDate) ?? currentDate
+            .chartXSelection(value: $selectedDate)
+            .chartScrollableAxes(.horizontal)
+            .chartScrollPosition(x: $scrollPosition)
+            .chartScrollTargetBehavior(
+                .valueAligned(
+                    matching: alignmentComponents,
+                    majorAlignment: .matching(alignmentComponents)
+                )
+            )
+            .chartXVisibleDomain(length: visibleDomainLength)
+            .frame(height: 200)
         }
         }
-        return completeData.reversed()
     }
     }
 
 
-    private func xAxisLabelValues() -> [(date: Date, label: String)] {
-        let data = chartData
-        let stride = selectedDays > 13 ? max(1, selectedDays / 7) : 1
-
-        return data.enumerated().compactMap { index, entry in
-            if index % stride == 0 || index == data.count - 1 {
-                return (date: entry.date, label: Formatter.dayFormatter.string(from: entry.date))
+    private struct TDDSelectionPopover: View {
+        let date: Date
+        let tdd: TDD
+
+        var body: some View {
+            VStack(alignment: .center, spacing: 4) {
+                Text(date.formatted(.dateTime.month().day()))
+                    .font(.caption)
+                    .foregroundStyle(.secondary)
+                Text("\(tdd.totalDailyDose?.formatted(.number.precision(.fractionLength(1))) ?? "0") U")
+                    .font(.callout.bold())
             }
             }
-            return nil
+            .padding(8)
+            .background(
+                RoundedRectangle(cornerRadius: 8)
+                    .fill(Color(.systemBackground))
+                    .shadow(radius: 2)
+            )
         }
         }
     }
     }
 }
 }
-
-private extension Decimal {
-    var doubleValue: Double {
-        NSDecimalNumber(decimal: self).doubleValue
-    }
-}

+ 3 - 2
Model/Helper/Determination+helper.swift

@@ -51,7 +51,8 @@ extension NSPredicate {
         )
         )
     }
     }
 
 
-    static func determinationPeriod(from startDate: Date, to endDate: Date) -> NSPredicate {
-        NSPredicate(format: "deliverAt >= %@ AND deliverAt <= %@", startDate as NSDate, endDate as NSDate)
+    static var determinationsForStats: NSPredicate {
+        let date = Date.threeMonthsAgo
+        return NSPredicate(format: "deliverAt >= %@", date as NSDate)
     }
     }
 }
 }