From 8a867fbb5b64d68ab68c2f73bd8499657dadf715 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:52:08 -0400 Subject: [PATCH 1/3] Fix inclusive date counting in stats period calculations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The stats model had a systematic off-by-one error in how it calculated the number of days in an analysis period, and used `Date()` (now, today) as the end boundary, including the current incomplete day in averages. ### Root causes 1. **Today was used as `endDate`** (`AggregatedStatsViewModel.updatePeriod`). Today is a partial day and should never be included in averages. 2. **Exclusive date diff used as day count** (`StatsDataService.updateDateRange`). `dateComponents([.day], from: start, to: end)` returns the number of whole days between two instants, which excludes the end day. A range of Apr 19–Apr 25 returned 6, not 7. 3. **Quick-select presets were off by one** (`DateRangePicker.setDateRange`). Pressing "7d" subtracted 7 days from the start of yesterday, producing an 8-day window (Apr 18–25) instead of a 7-day window (Apr 19–25). 4. **Day count label was exclusive** (`DateRangePicker.dayCount`). The "(N days)" header label used the same exclusive diff, showing one fewer day than the range actually covered. 5. **Bolus cutoff re-derived from `Date()`** (`SimpleStatsViewModel`). The cutoff for filtering bolus dates was recalculated as `Date() - requestedDays * 86400` instead of using `dataService.startDate`, making it inconsistent with the resolved date range after today was removed from the end boundary. 6. **Same re-derivation bug in `calculateActualDaysCovered`**. The helper also anchored its own cutoff to `Date()` rather than `dataService.startDate`. 7. **Carbs denominator used days-with-data, not period length** (`SimpleStatsViewModel`). `avgCarbs` divided total carbs by `dailyCarbs.count` (number of days that had at least one carb entry), which inflates the average whenever the user had carb-free days in the period. The band-aid `max(dailyCarbs.count, 1)` was a symptom of this. ## Fix **`AggregatedStatsViewModel.updatePeriod()`** - `endDate` = 23:59:59 of yesterday (last complete day), computed via `startOfDay(for: Date()) - 1 second` using the display calendar. - `startDate` = midnight of `endDay - (days - 1)` so that a "7d" period covers exactly 7 calendar days inclusive (e.g. Apr 19–Apr 25). **`StatsDataService.updateDateRange()`** - `daysToAnalyze` = `daysBetween + 1`, where `daysBetween` is the exclusive `dateComponents` diff between the start-of-day of each boundary. Computing on day-start timestamps avoids DST-induced sub-day remainders from inflating the count. **`DateRangePicker.setDateRange()`** - Start offset changed from `-(days)` to `-(days - 1)` so quick-select presets (7d, 14d, 30d, 90d) produce inclusive ranges. **`DateRangePicker.dayCount`** - Day count = exclusive diff between start-of-day boundaries + 1, ensuring the header label matches the actual number of days covered. **`SimpleStatsViewModel` — bolus cutoff** - `cutoffTime` now reads `dataService.startDate.timeIntervalSince1970` directly. This is consistent with the resolved period and avoids re-deriving a different value from the current clock. **`SimpleStatsViewModel` — carbs denominator** - Denominator changed from `dailyCarbs.count` to `dataService.daysToAnalyze` so that carb-free days are included in the average (total carbs spread over the full period, not just days with entries). **`SimpleStatsViewModel.calculateActualDaysCovered()`** - Cutoff changed from `Date() - requestedDays * 86400` to `dataService.startDate.timeIntervalSince1970` for the same reason as the bolus fix above. ## Time zone behaviour All day-boundary arithmetic uses `dateTimeUtils.displayCalendar()`, which applies the user's configured graph time zone or the device's current time zone. This means: - DST transitions are handled correctly: `startOfDay(for:)` and `date(byAdding: .day)` use calendar days, not fixed 86400-second intervals, so 23-hour and 25-hour DST days do not shift boundaries. - Travel (device time zone change) causes the analysis window to be recomputed relative to the new local midnight on the next load, which is the expected behaviour. - Users with a fixed graph time zone are fully insulated from travel: all boundaries stay anchored to the configured zone. --- LoopFollow/Stats/AggregatedStatsViewModel.swift | 7 +++++-- LoopFollow/Stats/DateRangePicker.swift | 6 ++++-- LoopFollow/Stats/SimpleStatsViewModel.swift | 6 +++--- LoopFollow/Stats/StatsDataService.swift | 8 +++++--- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/LoopFollow/Stats/AggregatedStatsViewModel.swift b/LoopFollow/Stats/AggregatedStatsViewModel.swift index 005de2f41..e87a33865 100644 --- a/LoopFollow/Stats/AggregatedStatsViewModel.swift +++ b/LoopFollow/Stats/AggregatedStatsViewModel.swift @@ -39,8 +39,11 @@ class AggregatedStatsViewModel: ObservableObject { } func updatePeriod(_ days: Int, completion: @escaping () -> Void = {}) { - let endDate = Date() - let startDate = dateTimeUtils.displayCalendar().date(byAdding: .day, value: -days, to: endDate) ?? endDate + let calendar = dateTimeUtils.displayCalendar() + let startOfToday = calendar.startOfDay(for: Date()) + let endDate = calendar.date(byAdding: .second, value: -1, to: startOfToday) ?? Date() + let endDayStart = calendar.startOfDay(for: endDate) + let startDate = calendar.date(byAdding: .day, value: -(days - 1), to: endDayStart) ?? endDayStart updateDateRange(start: startDate, end: endDate, completion: completion) } diff --git a/LoopFollow/Stats/DateRangePicker.swift b/LoopFollow/Stats/DateRangePicker.swift index 5bc9c334f..7bb36b356 100644 --- a/LoopFollow/Stats/DateRangePicker.swift +++ b/LoopFollow/Stats/DateRangePicker.swift @@ -31,7 +31,9 @@ struct DateRangePicker: View { private var dayCount: Int { let calendar = dateTimeUtils.displayCalendar() - return calendar.dateComponents([.day], from: startDate, to: endDate).day ?? 0 + let startDay = calendar.startOfDay(for: startDate) + let endDay = calendar.startOfDay(for: endDate) + return (calendar.dateComponents([.day], from: startDay, to: endDay).day ?? 0) + 1 } private var lastFullDay: Date { @@ -241,7 +243,7 @@ struct DateRangePicker: View { let calendar = dateTimeUtils.displayCalendar() endDate = lastFullDay let endDayStart = calendar.startOfDay(for: endDate) - let startDayStart = calendar.date(byAdding: .day, value: -days, to: endDayStart) ?? endDayStart + let startDayStart = calendar.date(byAdding: .day, value: -(days - 1), to: endDayStart) ?? endDayStart startDate = calendar.startOfDay(for: startDayStart) showStartDatePicker = false showEndDatePicker = false diff --git a/LoopFollow/Stats/SimpleStatsViewModel.swift b/LoopFollow/Stats/SimpleStatsViewModel.swift index 960e17c57..a3b97f1d1 100644 --- a/LoopFollow/Stats/SimpleStatsViewModel.swift +++ b/LoopFollow/Stats/SimpleStatsViewModel.swift @@ -66,7 +66,7 @@ class SimpleStatsViewModel: ObservableObject { let smbTotal = smbInPeriod.reduce(0.0) { $0 + $1.value } let totalBolusInPeriod = bolusTotal + smbTotal - let cutoffTime = Date().timeIntervalSince1970 - (Double(dataService.daysToAnalyze) * 24 * 60 * 60) + let cutoffTime = dataService.startDate.timeIntervalSince1970 let allBolusDates = (bolusesInPeriod + smbInPeriod).map { $0.date }.filter { $0 >= cutoffTime } let actualDays = calculateActualDaysCovered(dates: allBolusDates, requestedDays: dataService.daysToAnalyze) @@ -93,7 +93,7 @@ class SimpleStatsViewModel: ObservableObject { let totalCarbsInPeriod = dailyCarbs.values.reduce(0.0, +) - let daysWithData = max(dailyCarbs.count, 1) + let daysWithData = dataService.daysToAnalyze if daysWithData > 0 { avgCarbs = totalCarbsInPeriod / Double(daysWithData) @@ -295,7 +295,7 @@ class SimpleStatsViewModel: ObservableObject { guard !dates.isEmpty else { return requestedDays } let calendar = dateTimeUtils.displayCalendar() - let cutoffTime = Date().timeIntervalSince1970 - (Double(requestedDays) * 24 * 60 * 60) + let cutoffTime = dataService.startDate.timeIntervalSince1970 let filteredDates = dates.filter { $0 >= cutoffTime } var uniqueDays = Set() diff --git a/LoopFollow/Stats/StatsDataService.swift b/LoopFollow/Stats/StatsDataService.swift index 80ea66b54..4aa5b7c7f 100644 --- a/LoopFollow/Stats/StatsDataService.swift +++ b/LoopFollow/Stats/StatsDataService.swift @@ -32,9 +32,11 @@ class StatsDataService { func updateDateRange(start: Date, end: Date) { startDate = start endDate = end - // Also update daysToAnalyze for compatibility with existing code - let daysBetween = dateTimeUtils.displayCalendar().dateComponents([.day], from: start, to: end).day ?? 14 - daysToAnalyze = max(daysBetween, 1) + let calendar = dateTimeUtils.displayCalendar() + let startDay = calendar.startOfDay(for: start) + let endDay = calendar.startOfDay(for: end) + let daysBetween = calendar.dateComponents([.day], from: startDay, to: endDay).day ?? 13 + daysToAnalyze = daysBetween + 1 } func ensureDataAvailable(onProgress: @escaping () -> Void, completion: @escaping () -> Void) { From e33f3f32cdfa7c3d905529070714edaaf0cac3d9 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:55:18 -0400 Subject: [PATCH 2/3] Fix initial stats view opening with exclusive 7-day offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AggregatedStatsView.init() was hardcoding the initial @State dates with value: -7 from endDayStart, producing an 8-day window (Apr 18–Apr 25) instead of the intended 7-day inclusive window (Apr 19–Apr 25). The previous commit fixed setDateRange() and updatePeriod() but missed this init(), which bypasses both and seeds the @State directly. --- LoopFollow/Stats/AggregatedStatsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopFollow/Stats/AggregatedStatsView.swift b/LoopFollow/Stats/AggregatedStatsView.swift index 35dfeb29d..13ac1a02d 100644 --- a/LoopFollow/Stats/AggregatedStatsView.swift +++ b/LoopFollow/Stats/AggregatedStatsView.swift @@ -28,7 +28,7 @@ struct AggregatedStatsView: View { let startOfToday = calendar.startOfDay(for: Date()) let end = calendar.date(byAdding: .second, value: -1, to: startOfToday) ?? Date() let endDay = calendar.startOfDay(for: end) - let startDay = calendar.date(byAdding: .day, value: -7, to: endDay) ?? endDay + let startDay = calendar.date(byAdding: .day, value: -(7 - 1), to: endDay) ?? endDay let start = calendar.startOfDay(for: startDay) _startDate = State(initialValue: start) _endDate = State(initialValue: end) From 629faab818f9d4f347317755aee86d9cfab3c09a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 03:39:17 +0000 Subject: [PATCH 3/3] Extract N-day range rule into StatsDateRange The "last complete N-day period" calculation was duplicated in AggregatedStatsView, AggregatedStatsViewModel, and DateRangePicker. Centralise it in a new StatsDateRange.lastComplete(days:) utility and replace all three inline copies. https://claude.ai/code/session_016oKb1eyTs8TfMq7drfmcg3 --- LoopFollow/Stats/AggregatedStatsView.swift | 11 +++-------- LoopFollow/Stats/AggregatedStatsViewModel.swift | 8 ++------ LoopFollow/Stats/DateRangePicker.swift | 12 ++++-------- LoopFollow/Stats/StatsDateRange.swift | 17 +++++++++++++++++ 4 files changed, 26 insertions(+), 22 deletions(-) create mode 100644 LoopFollow/Stats/StatsDateRange.swift diff --git a/LoopFollow/Stats/AggregatedStatsView.swift b/LoopFollow/Stats/AggregatedStatsView.swift index 13ac1a02d..951e0b230 100644 --- a/LoopFollow/Stats/AggregatedStatsView.swift +++ b/LoopFollow/Stats/AggregatedStatsView.swift @@ -24,14 +24,9 @@ struct AggregatedStatsView: View { _showGMI = State(initialValue: Storage.shared.showGMI.value) _showStdDev = State(initialValue: Storage.shared.showStdDev.value) - let calendar = dateTimeUtils.displayCalendar() - let startOfToday = calendar.startOfDay(for: Date()) - let end = calendar.date(byAdding: .second, value: -1, to: startOfToday) ?? Date() - let endDay = calendar.startOfDay(for: end) - let startDay = calendar.date(byAdding: .day, value: -(7 - 1), to: endDay) ?? endDay - let start = calendar.startOfDay(for: startDay) - _startDate = State(initialValue: start) - _endDate = State(initialValue: end) + let range = StatsDateRange.lastComplete(days: 7) + _startDate = State(initialValue: range.start) + _endDate = State(initialValue: range.end) } var body: some View { diff --git a/LoopFollow/Stats/AggregatedStatsViewModel.swift b/LoopFollow/Stats/AggregatedStatsViewModel.swift index e87a33865..253352e48 100644 --- a/LoopFollow/Stats/AggregatedStatsViewModel.swift +++ b/LoopFollow/Stats/AggregatedStatsViewModel.swift @@ -39,12 +39,8 @@ class AggregatedStatsViewModel: ObservableObject { } func updatePeriod(_ days: Int, completion: @escaping () -> Void = {}) { - let calendar = dateTimeUtils.displayCalendar() - let startOfToday = calendar.startOfDay(for: Date()) - let endDate = calendar.date(byAdding: .second, value: -1, to: startOfToday) ?? Date() - let endDayStart = calendar.startOfDay(for: endDate) - let startDate = calendar.date(byAdding: .day, value: -(days - 1), to: endDayStart) ?? endDayStart - updateDateRange(start: startDate, end: endDate, completion: completion) + let range = StatsDateRange.lastComplete(days: days) + updateDateRange(start: range.start, end: range.end, completion: completion) } func updateDateRange(start: Date, end: Date, completion: @escaping () -> Void = {}) { diff --git a/LoopFollow/Stats/DateRangePicker.swift b/LoopFollow/Stats/DateRangePicker.swift index 7bb36b356..8b15e10d1 100644 --- a/LoopFollow/Stats/DateRangePicker.swift +++ b/LoopFollow/Stats/DateRangePicker.swift @@ -37,9 +37,7 @@ struct DateRangePicker: View { } private var lastFullDay: Date { - let calendar = dateTimeUtils.displayCalendar() - let startOfToday = calendar.startOfDay(for: Date()) - return calendar.date(byAdding: .second, value: -1, to: startOfToday) ?? Date() + StatsDateRange.lastComplete(days: 1).end } var body: some View { @@ -240,11 +238,9 @@ struct DateRangePicker: View { } private func setDateRange(days: Int) { - let calendar = dateTimeUtils.displayCalendar() - endDate = lastFullDay - let endDayStart = calendar.startOfDay(for: endDate) - let startDayStart = calendar.date(byAdding: .day, value: -(days - 1), to: endDayStart) ?? endDayStart - startDate = calendar.startOfDay(for: startDayStart) + let range = StatsDateRange.lastComplete(days: days) + startDate = range.start + endDate = range.end showStartDatePicker = false showEndDatePicker = false onDateChange() diff --git a/LoopFollow/Stats/StatsDateRange.swift b/LoopFollow/Stats/StatsDateRange.swift new file mode 100644 index 000000000..e2e00458b --- /dev/null +++ b/LoopFollow/Stats/StatsDateRange.swift @@ -0,0 +1,17 @@ +// LoopFollow +// StatsDateRange.swift + +import Foundation + +enum StatsDateRange { + /// Returns start/end dates for the last complete N-day period. + /// End is 23:59:59 of yesterday; start is 00:00:00 of the day N days back. + static func lastComplete(days: Int) -> (start: Date, end: Date) { + let calendar = dateTimeUtils.displayCalendar() + let startOfToday = calendar.startOfDay(for: Date()) + let end = calendar.date(byAdding: .second, value: -1, to: startOfToday) ?? Date() + let endDayStart = calendar.startOfDay(for: end) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: endDayStart) ?? endDayStart + return (start: calendar.startOfDay(for: start), end: end) + } +}