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

Small fixes for Bolus, TDD and Meal Charts

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

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

@@ -26,7 +26,7 @@ extension Stat.StateModel {
 
     /// Fetches and processes bolus statistics for a specific date range
     /// - Returns: Array of BolusStats containing daily bolus statistics
-    func fetchBolusStats() async -> [BolusStats] {
+    private func fetchBolusStats() async -> [BolusStats] {
         let calendar = Calendar.current
 
         // Fetch bolus records from Core Data
@@ -42,14 +42,27 @@ extension Stat.StateModel {
         return await bolusTaskContext.perform {
             guard let fetchedResults = results as? [BolusStored] else { return [] }
 
-            // Group boluses by day
-            let groupedByDay = Dictionary(grouping: fetchedResults) { bolus -> Date in
+            // Group boluses by day or hour depending on selected duration
+            let groupedByTime = Dictionary(grouping: fetchedResults) { bolus -> Date in
                 guard let timestamp = bolus.pumpEvent?.timestamp else { return Date() }
-                return calendar.startOfDay(for: timestamp)
+
+                if self.selectedDurationForInsulinStats == .Day {
+                    // For Day view, group by hour
+                    let components = calendar.dateComponents([.year, .month, .day, .hour], from: timestamp)
+                    return calendar.date(from: components) ?? Date()
+                } else {
+                    // For other views, group by day
+                    return calendar.startOfDay(for: timestamp)
+                }
             }
 
-            // Calculate daily totals
-            return groupedByDay.map { date, boluses -> BolusStats in
+            // Get all unique time points
+            let timePoints = groupedByTime.keys.sorted()
+
+            // Calculate totals for each time point
+            return timePoints.map { timePoint in
+                let boluses = groupedByTime[timePoint, default: []]
+
                 // Calculate total manual boluses (excluding SMB and external)
                 let manualBolus = boluses
                     .filter { !($0.isExternal || $0.isSMB) }
@@ -66,7 +79,7 @@ extension Stat.StateModel {
                     .reduce(0.0) { $0 + (($1.amount as? Decimal) ?? 0).doubleValue }
 
                 return BolusStats(
-                    date: date,
+                    date: timePoint,
                     manualBolus: manualBolus,
                     smb: smb,
                     external: external

+ 15 - 9
FreeAPS/Sources/Modules/Stat/StatStateModel+Setup/MealStatsSetup.swift

@@ -46,25 +46,31 @@ extension Stat.StateModel {
 
             let calendar = Calendar.current
 
-            // Group entries by day using calendar's startOfDay
+            // Group entries by day or hour depending on selected duration
             let groupedEntries = Dictionary(grouping: fetchedResults) { entry in
-                calendar.startOfDay(for: entry.date ?? Date())
+                if self.selectedDurationForMealStats == .Day {
+                    // For Day view, group by hour
+                    let components = calendar.dateComponents([.year, .month, .day, .hour], from: entry.date ?? Date())
+                    return calendar.date(from: components) ?? Date()
+                } else {
+                    // For other views, group by day
+                    return calendar.startOfDay(for: entry.date ?? Date())
+                }
             }
 
-            // Get all unique dates from the entries - they'll already be sorted
-            let dates = groupedEntries.keys.sorted()
+            // Get all unique dates/hours from the entries
+            let timePoints = groupedEntries.keys.sorted()
 
-            // Calculate statistics for each day
-            return dates.map { date in
-                let entries = groupedEntries[date, default: []]
+            // Calculate statistics for each time point
+            return timePoints.map { timePoint in
+                let entries = groupedEntries[timePoint, default: []]
 
-                // Sum up macronutrients for the day
                 let carbsTotal = entries.reduce(0.0) { $0 + $1.carbs }
                 let fatTotal = entries.reduce(0.0) { $0 + $1.fat }
                 let proteinTotal = entries.reduce(0.0) { $0 + $1.protein }
 
                 return MealStats(
-                    date: date,
+                    date: timePoint,
                     carbs: carbsTotal,
                     fat: fatTotal,
                     protein: proteinTotal

+ 21 - 7
FreeAPS/Sources/Modules/Stat/StatStateModel+Setup/TDDSetup.swift

@@ -24,26 +24,40 @@ extension Stat.StateModel {
         return await determinationFetchContext.perform {
             guard let fetchedResults = results as? [[String: Any]] else { return [] }
 
-            // Group determinations by day
             let calendar = Calendar.current
-            let groupedByDay = Dictionary(grouping: fetchedResults) { result -> Date in
+
+            // Group determinations by day or hour
+            let groupedByTime = Dictionary(grouping: fetchedResults) { result -> Date in
                 guard let deliverAt = result["deliverAt"] as? Date else { return Date() }
-                return calendar.startOfDay(for: deliverAt)
+
+                if self.selectedDurationForInsulinStats == .Day {
+                    // For Day view, group by hour
+                    let components = calendar.dateComponents([.year, .month, .day, .hour], from: deliverAt)
+                    return calendar.date(from: components) ?? Date()
+                } else {
+                    // For other views, group by day
+                    return calendar.startOfDay(for: deliverAt)
+                }
             }
 
-            // Calculate total daily doses for each day
-            return groupedByDay.map { date, determinations -> TDD in
+            // Get all unique time points
+            let timePoints = groupedByTime.keys.sorted()
+
+            // Calculate totals for each time point
+            return timePoints.map { timePoint in
+                let determinations = groupedByTime[timePoint, default: []]
+
                 let totalDose = determinations.reduce(Decimal.zero) { sum, determination in
                     sum + (determination["totalDailyDose"] as? Decimal ?? 0)
                 }
 
-                // Calculate average dose for the day
+                // Calculate average dose for the time period
                 let count = Decimal(determinations.count)
                 let averageDose = count > 0 ? totalDose / count : 0
 
                 return TDD(
                     totalDailyDose: averageDose,
-                    timestamp: date
+                    timestamp: timePoint
                 )
             }
         }

+ 52 - 13
FreeAPS/Sources/Modules/Stat/View/ViewElements/BolusStatsView.swift

@@ -10,6 +10,7 @@ struct BolusStatsView: View {
     @State private var selectedDate: Date?
     @State private var currentAverages: (manual: Double, smb: Double, external: Double) = (0, 0, 0)
     @State private var updateTimer = Stat.UpdateTimer()
+    @State private var isScrolling = false
 
     private var visibleDomainLength: TimeInterval {
         switch selectedDuration {
@@ -30,7 +31,7 @@ struct BolusStatsView: View {
     private var dateFormat: Date.FormatStyle {
         switch selectedDuration {
         case .Day:
-            return .dateTime.weekday(.abbreviated)
+            return .dateTime.hour()
         case .Week:
             return .dateTime.weekday(.abbreviated)
         case .Month:
@@ -69,6 +70,37 @@ struct BolusStatsView: View {
         }
     }
 
+    private func formatVisibleDateRange(showTimeRange: Bool = false) -> String {
+        let start = visibleDateRange.start
+        let end = visibleDateRange.end
+        let calendar = Calendar.current
+
+        switch selectedDuration {
+        case .Day:
+            let today = Date()
+            let isToday = calendar.isDate(start, inSameDayAs: today)
+            let isYesterday = calendar.isDate(start, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!)
+
+            if isToday || isYesterday, !showTimeRange {
+                return isToday ? "Today" : "Yesterday"
+            }
+
+            let timeRange =
+                "\(start.formatted(.dateTime.hour(.twoDigits(amPM: .wide)))) - \(end.formatted(.dateTime.hour(.twoDigits(amPM: .wide))))"
+
+            if isToday {
+                return "Today, \(timeRange)"
+            } else if isYesterday {
+                return "Yesterday, \(timeRange)"
+            } else {
+                return "\(start.formatted(.dateTime.month().day())), \(timeRange)"
+            }
+
+        default:
+            return "\(start.formatted(.dateTime.month().day())) - \(end.formatted(.dateTime.month().day()))"
+        }
+    }
+
     var body: some View {
         VStack(alignment: .leading, spacing: 8) {
             statsView
@@ -77,21 +109,21 @@ struct BolusStatsView: View {
                 ForEach(bolusStats) { stat in
                     // External Bolus (Bottom)
                     BarMark(
-                        x: .value("Date", stat.date, unit: .day),
+                        x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
                         y: .value("Amount", stat.external)
                     )
                     .foregroundStyle(by: .value("Type", "External"))
 
                     // SMB (Middle)
                     BarMark(
-                        x: .value("Date", stat.date, unit: .day),
+                        x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
                         y: .value("Amount", stat.smb)
                     )
                     .foregroundStyle(by: .value("Type", "SMB"))
 
                     // Manual Bolus (Top)
                     BarMark(
-                        x: .value("Date", stat.date, unit: .day),
+                        x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
                         y: .value("Amount", stat.manualBolus)
                     )
                     .foregroundStyle(by: .value("Type", "Manual"))
@@ -123,25 +155,30 @@ struct BolusStatsView: View {
                 AxisMarks(position: .trailing) { value in
                     if let amount = value.as(Double.self) {
                         AxisValueLabel {
-                            Text(amount.formatted(.number.precision(.fractionLength(1))) + " U")
+                            Text(amount.formatted(.number.precision(.fractionLength(0))) + " U")
                         }
                         AxisGridLine()
                     }
                 }
             }
             .chartXAxis {
-                AxisMarks(preset: .aligned, values: .stride(by: .day)) { value in
+                AxisMarks(preset: .aligned, values: .stride(by: selectedDuration == .Day ? .hour : .day)) { value in
                     if let date = value.as(Date.self) {
                         let day = Calendar.current.component(.day, from: date)
+                        let hour = Calendar.current.component(.hour, from: date)
 
                         switch selectedDuration {
+                        case .Day:
+                            if hour % 6 == 0 { // Show only every 6 hours (0, 6, 12, 18)
+                                AxisValueLabel(format: dateFormat, centered: true)
+                                AxisGridLine()
+                            }
                         case .Month:
                             if day % 5 == 0 { // Only show every 5th day
                                 AxisValueLabel(format: dateFormat, centered: true)
                                 AxisGridLine()
                             }
                         case .Total:
-                            // Only show January, April, July, October
                             if day == 1 && Calendar.current.component(.month, from: date) % 3 == 1 {
                                 AxisValueLabel(format: dateFormat, centered: true)
                                 AxisGridLine()
@@ -158,7 +195,9 @@ struct BolusStatsView: View {
             .chartScrollPosition(x: $scrollPosition)
             .chartScrollTargetBehavior(
                 .valueAligned(
-                    matching: DateComponents(hour: 0), // Align to start of day
+                    matching: selectedDuration == .Day ?
+                        DateComponents(minute: 0) : // Align to next hour for Day view
+                        DateComponents(hour: 0), // Align to start of day for other views
                     majorAlignment: .matching(alignmentComponents)
                 )
             )
@@ -170,8 +209,10 @@ struct BolusStatsView: View {
             updateAverages()
         }
         .onChange(of: scrollPosition) {
+            isScrolling = true
             updateTimer.scheduleUpdate {
                 updateAverages()
+                isScrolling = false
             }
         }
         .onChange(of: selectedDuration) {
@@ -223,11 +264,9 @@ struct BolusStatsView: View {
 
             Spacer()
 
-            Text(
-                "\(visibleDateRange.start.formatted(.dateTime.month().day())) - \(visibleDateRange.end.formatted(.dateTime.month().day()))"
-            )
-            .font(.subheadline)
-            .foregroundStyle(.secondary)
+            Text(formatVisibleDateRange(showTimeRange: isScrolling))
+                .font(.subheadline)
+                .foregroundStyle(.secondary)
         }
     }
 }

+ 55 - 13
FreeAPS/Sources/Modules/Stat/View/ViewElements/MealStatsView.swift

@@ -10,6 +10,7 @@ struct MealStatsView: View {
     @State private var selectedDate: Date?
     @State private var currentAverages: (carbs: Double, fat: Double, protein: Double) = (0, 0, 0)
     @State private var updateTimer = Stat.UpdateTimer()
+    @State private var isScrolling = false
 
     private var visibleDomainLength: TimeInterval {
         switch selectedDuration {
@@ -30,7 +31,7 @@ struct MealStatsView: View {
     private var dateFormat: Date.FormatStyle {
         switch selectedDuration {
         case .Day:
-            return .dateTime.weekday(.abbreviated)
+            return .dateTime.hour()
         case .Week:
             return .dateTime.weekday(.abbreviated)
         case .Month:
@@ -69,6 +70,37 @@ struct MealStatsView: View {
         }
     }
 
+    private func formatVisibleDateRange(showTimeRange: Bool = false) -> String {
+        let start = visibleDateRange.start
+        let end = visibleDateRange.end
+        let calendar = Calendar.current
+
+        switch selectedDuration {
+        case .Day:
+            let today = Date()
+            let isToday = calendar.isDate(start, inSameDayAs: today)
+            let isYesterday = calendar.isDate(start, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!)
+
+            if isToday || isYesterday, !showTimeRange {
+                return isToday ? "Today" : "Yesterday"
+            }
+
+            let timeRange =
+                "\(start.formatted(.dateTime.hour(.twoDigits(amPM: .wide)))) - \(end.formatted(.dateTime.hour(.twoDigits(amPM: .wide))))"
+
+            if isToday {
+                return "Today, \(timeRange)"
+            } else if isYesterday {
+                return "Yesterday, \(timeRange)"
+            } else {
+                return "\(start.formatted(.dateTime.month().day())), \(timeRange)"
+            }
+
+        default:
+            return "\(start.formatted(.dateTime.month().day())) - \(end.formatted(.dateTime.month().day()))"
+        }
+    }
+
     var body: some View {
         VStack(alignment: .leading, spacing: 8) {
             statsView
@@ -77,21 +109,21 @@ struct MealStatsView: View {
                 ForEach(mealStats) { stat in
                     // Carbs (Bottom)
                     BarMark(
-                        x: .value("Date", stat.date, unit: .day),
+                        x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
                         y: .value("Amount", stat.carbs)
                     )
                     .foregroundStyle(by: .value("Type", "Carbs"))
 
                     // Fat (Middle)
                     BarMark(
-                        x: .value("Date", stat.date, unit: .day),
+                        x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
                         y: .value("Amount", stat.fat)
                     )
                     .foregroundStyle(by: .value("Type", "Fat"))
 
                     // Protein (Top)
                     BarMark(
-                        x: .value("Date", stat.date, unit: .day),
+                        x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
                         y: .value("Amount", stat.protein)
                     )
                     .foregroundStyle(by: .value("Type", "Protein"))
@@ -123,18 +155,24 @@ struct MealStatsView: View {
                 AxisMarks(position: .trailing) { value in
                     if let amount = value.as(Double.self) {
                         AxisValueLabel {
-                            Text(amount.formatted(.number.precision(.fractionLength(1))) + " g")
+                            Text(amount.formatted(.number.precision(.fractionLength(0))) + " g")
                         }
                         AxisGridLine()
                     }
                 }
             }
             .chartXAxis {
-                AxisMarks(preset: .aligned, values: .stride(by: .day)) { value in
+                AxisMarks(preset: .aligned, values: .stride(by: selectedDuration == .Day ? .hour : .day)) { value in
                     if let date = value.as(Date.self) {
                         let day = Calendar.current.component(.day, from: date)
+                        let hour = Calendar.current.component(.hour, from: date)
 
                         switch selectedDuration {
+                        case .Day:
+                            if hour % 6 == 0 { // Show only every 6 hours (0, 6, 12, 18)
+                                AxisValueLabel(format: dateFormat, centered: true)
+                                AxisGridLine()
+                            }
                         case .Month:
                             if day % 5 == 0 { // Only show every 5th day
                                 AxisValueLabel(format: dateFormat, centered: true)
@@ -158,8 +196,12 @@ struct MealStatsView: View {
             .chartScrollPosition(x: $scrollPosition)
             .chartScrollTargetBehavior(
                 .valueAligned(
-                    matching: DateComponents(hour: 0), // Align to start of day
-                    majorAlignment: .matching(alignmentComponents)
+                    matching: selectedDuration == .Day ?
+                        DateComponents(minute: 0) : // Align to next hour for Day view
+                        DateComponents(hour: 0), // Align to start of day for other views
+                    majorAlignment: .matching(
+                        alignmentComponents
+                    )
                 )
             )
             .chartXVisibleDomain(length: visibleDomainLength)
@@ -170,8 +212,10 @@ struct MealStatsView: View {
             updateAverages()
         }
         .onChange(of: scrollPosition) {
+            isScrolling = true
             updateTimer.scheduleUpdate {
                 updateAverages()
+                isScrolling = false
             }
         }
         .onChange(of: selectedDuration) {
@@ -223,11 +267,9 @@ struct MealStatsView: View {
 
             Spacer()
 
-            Text(
-                "\(visibleDateRange.start.formatted(.dateTime.month().day())) - \(visibleDateRange.end.formatted(.dateTime.month().day()))"
-            )
-            .font(.subheadline)
-            .foregroundStyle(.secondary)
+            Text(formatVisibleDateRange(showTimeRange: isScrolling))
+                .font(.subheadline)
+                .foregroundStyle(.secondary)
         }
     }
 }

+ 50 - 11
FreeAPS/Sources/Modules/Stat/View/ViewElements/TDDChart.swift

@@ -11,6 +11,7 @@ struct TDDChartView: View {
     @State private var currentAverageTDD: Decimal = 0
     @State private var currentMedianTDD: Decimal = 0
     @State private var selectedDate: Date?
+    @State private var isScrolling = false
 
     @State private var updateTimer = Stat.UpdateTimer()
 
@@ -35,7 +36,7 @@ struct TDDChartView: View {
     private var dateFormat: Date.FormatStyle {
         switch selectedDuration {
         case .Day:
-            return .dateTime.weekday(.abbreviated)
+            return .dateTime.hour()
         case .Week:
             return .dateTime.weekday(.abbreviated)
         case .Month:
@@ -90,8 +91,10 @@ struct TDDChartView: View {
                 updateStats()
             }
             .onChange(of: scrollPosition) {
+                isScrolling = true
                 updateTimer.scheduleUpdate {
                     updateStats()
+                    isScrolling = false
                 }
             }
             .onChange(of: selectedDuration) {
@@ -109,8 +112,8 @@ struct TDDChartView: View {
             Chart {
                 ForEach(tddStats) { entry in
                     BarMark(
-                        x: .value("Date", entry.timestamp ?? Date(), unit: .day),
-                        y: .value("Insulin", entry.totalDailyDose ?? 0)
+                        x: .value("Date", entry.timestamp ?? Date(), unit: selectedDuration == .Day ? .hour : .day),
+                        y: .value("TDD", entry.totalDailyDose ?? 0)
                     )
                     .foregroundStyle(Color.insulin.gradient)
                 }
@@ -138,18 +141,23 @@ struct TDDChartView: View {
                 }
             }
             .chartXAxis {
-                AxisMarks(preset: .aligned, values: .stride(by: .day)) { value in
+                AxisMarks(preset: .aligned, values: .stride(by: selectedDuration == .Day ? .hour : .day)) { value in
                     if let date = value.as(Date.self) {
                         let day = Calendar.current.component(.day, from: date)
+                        let hour = Calendar.current.component(.hour, from: date)
 
                         switch selectedDuration {
+                        case .Day:
+                            if hour % 6 == 0 { // Show only every 6 hours
+                                AxisValueLabel(format: dateFormat, centered: true)
+                                AxisGridLine()
+                            }
                         case .Month:
                             if day % 5 == 0 { // Only show every 5th day
                                 AxisValueLabel(format: dateFormat, centered: true)
                                 AxisGridLine()
                             }
                         case .Total:
-                            // Only show January, April, July, October
                             if day == 1 && Calendar.current.component(.month, from: date) % 3 == 1 {
                                 AxisValueLabel(format: dateFormat, centered: true)
                                 AxisGridLine()
@@ -166,7 +174,9 @@ struct TDDChartView: View {
             .chartScrollPosition(x: $scrollPosition)
             .chartScrollTargetBehavior(
                 .valueAligned(
-                    matching: DateComponents(hour: 0), // Align to start of day
+                    matching: selectedDuration == .Day ?
+                        DateComponents(minute: 0) : // Align to next hour for Day view
+                        DateComponents(hour: 0), // Align to start of day for other views
                     majorAlignment: .matching(alignmentComponents)
                 )
             )
@@ -206,11 +216,40 @@ struct TDDChartView: View {
 
             Spacer()
 
-            Text(
-                "\(visibleDateRange.start.formatted(.dateTime.month().day())) - \(visibleDateRange.end.formatted(.dateTime.month().day()))"
-            )
-            .font(.subheadline)
-            .foregroundStyle(.secondary)
+            Text(formatVisibleDateRange(showTimeRange: isScrolling))
+                .font(.subheadline)
+                .foregroundStyle(.secondary)
+        }
+    }
+
+    private func formatVisibleDateRange(showTimeRange: Bool = false) -> String {
+        let start = visibleDateRange.start
+        let end = visibleDateRange.end
+        let calendar = Calendar.current
+
+        switch selectedDuration {
+        case .Day:
+            let today = Date()
+            let isToday = calendar.isDate(start, inSameDayAs: today)
+            let isYesterday = calendar.isDate(start, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!)
+
+            if isToday || isYesterday, !showTimeRange {
+                return isToday ? "Today" : "Yesterday"
+            }
+
+            let timeRange =
+                "\(start.formatted(.dateTime.hour(.twoDigits(amPM: .wide)))) - \(end.formatted(.dateTime.hour(.twoDigits(amPM: .wide))))"
+
+            if isToday {
+                return "Today, \(timeRange)"
+            } else if isYesterday {
+                return "Yesterday, \(timeRange)"
+            } else {
+                return "\(start.formatted(.dateTime.month().day())), \(timeRange)"
+            }
+
+        default:
+            return "\(start.formatted(.dateTime.month().day())) - \(end.formatted(.dateTime.month().day()))"
         }
     }