Ver código fonte

Fix/stats inclusive range - replaces #621 (#629)

* Fix inclusive date counting in stats period calculations

## 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.

* Fix initial stats view opening with exclusive 7-day offset

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.

* 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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Phil A 2 meses atrás
pai
commit
5ab62093cf

+ 3 - 8
LoopFollow/Stats/AggregatedStatsView.swift

@@ -24,14 +24,9 @@ struct AggregatedStatsView: View {
         _showGMI = State(initialValue: UnitSettingsStore.shared.glycemicMetricMode == .gmi)
         _showGMI = State(initialValue: UnitSettingsStore.shared.glycemicMetricMode == .gmi)
         _showStdDev = State(initialValue: UnitSettingsStore.shared.variabilityMetricMode == .stdDeviation)
         _showStdDev = State(initialValue: UnitSettingsStore.shared.variabilityMetricMode == .stdDeviation)
 
 
-        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, 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 {
     var body: some View {

+ 2 - 3
LoopFollow/Stats/AggregatedStatsViewModel.swift

@@ -39,9 +39,8 @@ class AggregatedStatsViewModel: ObservableObject {
     }
     }
 
 
     func updatePeriod(_ days: Int, completion: @escaping () -> Void = {}) {
     func updatePeriod(_ days: Int, completion: @escaping () -> Void = {}) {
-        let endDate = Date()
-        let startDate = dateTimeUtils.displayCalendar().date(byAdding: .day, value: -days, to: endDate) ?? endDate
-        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 = {}) {
     func updateDateRange(start: Date, end: Date, completion: @escaping () -> Void = {}) {

+ 7 - 9
LoopFollow/Stats/DateRangePicker.swift

@@ -31,13 +31,13 @@ struct DateRangePicker: View {
 
 
     private var dayCount: Int {
     private var dayCount: Int {
         let calendar = dateTimeUtils.displayCalendar()
         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 {
     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 {
     var body: some View {
@@ -238,11 +238,9 @@ struct DateRangePicker: View {
     }
     }
 
 
     private func setDateRange(days: Int) {
     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, to: endDayStart) ?? endDayStart
-        startDate = calendar.startOfDay(for: startDayStart)
+        let range = StatsDateRange.lastComplete(days: days)
+        startDate = range.start
+        endDate = range.end
         showStartDatePicker = false
         showStartDatePicker = false
         showEndDatePicker = false
         showEndDatePicker = false
         onDateChange()
         onDateChange()

+ 3 - 3
LoopFollow/Stats/SimpleStatsViewModel.swift

@@ -67,7 +67,7 @@ class SimpleStatsViewModel: ObservableObject {
         let smbTotal = smbInPeriod.reduce(0.0) { $0 + $1.value }
         let smbTotal = smbInPeriod.reduce(0.0) { $0 + $1.value }
         let totalBolusInPeriod = bolusTotal + smbTotal
         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 allBolusDates = (bolusesInPeriod + smbInPeriod).map { $0.date }.filter { $0 >= cutoffTime }
         let actualDays = calculateActualDaysCovered(dates: allBolusDates, requestedDays: dataService.daysToAnalyze)
         let actualDays = calculateActualDaysCovered(dates: allBolusDates, requestedDays: dataService.daysToAnalyze)
 
 
@@ -94,7 +94,7 @@ class SimpleStatsViewModel: ObservableObject {
 
 
         let totalCarbsInPeriod = dailyCarbs.values.reduce(0.0, +)
         let totalCarbsInPeriod = dailyCarbs.values.reduce(0.0, +)
 
 
-        let daysWithData = max(dailyCarbs.count, 1)
+        let daysWithData = dataService.daysToAnalyze
 
 
         if daysWithData > 0 {
         if daysWithData > 0 {
             avgCarbs = totalCarbsInPeriod / Double(daysWithData)
             avgCarbs = totalCarbsInPeriod / Double(daysWithData)
@@ -296,7 +296,7 @@ class SimpleStatsViewModel: ObservableObject {
         guard !dates.isEmpty else { return requestedDays }
         guard !dates.isEmpty else { return requestedDays }
 
 
         let calendar = dateTimeUtils.displayCalendar()
         let calendar = dateTimeUtils.displayCalendar()
-        let cutoffTime = Date().timeIntervalSince1970 - (Double(requestedDays) * 24 * 60 * 60)
+        let cutoffTime = dataService.startDate.timeIntervalSince1970
         let filteredDates = dates.filter { $0 >= cutoffTime }
         let filteredDates = dates.filter { $0 >= cutoffTime }
 
 
         var uniqueDays = Set<Date>()
         var uniqueDays = Set<Date>()

+ 5 - 3
LoopFollow/Stats/StatsDataService.swift

@@ -32,9 +32,11 @@ class StatsDataService {
     func updateDateRange(start: Date, end: Date) {
     func updateDateRange(start: Date, end: Date) {
         startDate = start
         startDate = start
         endDate = end
         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) {
     func ensureDataAvailable(onProgress: @escaping () -> Void, completion: @escaping () -> Void) {

+ 17 - 0
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)
+    }
+}