Skip to content

Commit 5ab6209

Browse files
MtlPhilclaude
andauthored
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>
1 parent 3ffae49 commit 5ab6209

6 files changed

Lines changed: 37 additions & 26 deletions

File tree

LoopFollow/Stats/AggregatedStatsView.swift

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,9 @@ struct AggregatedStatsView: View {
2424
_showGMI = State(initialValue: UnitSettingsStore.shared.glycemicMetricMode == .gmi)
2525
_showStdDev = State(initialValue: UnitSettingsStore.shared.variabilityMetricMode == .stdDeviation)
2626

27-
let calendar = dateTimeUtils.displayCalendar()
28-
let startOfToday = calendar.startOfDay(for: Date())
29-
let end = calendar.date(byAdding: .second, value: -1, to: startOfToday) ?? Date()
30-
let endDay = calendar.startOfDay(for: end)
31-
let startDay = calendar.date(byAdding: .day, value: -7, to: endDay) ?? endDay
32-
let start = calendar.startOfDay(for: startDay)
33-
_startDate = State(initialValue: start)
34-
_endDate = State(initialValue: end)
27+
let range = StatsDateRange.lastComplete(days: 7)
28+
_startDate = State(initialValue: range.start)
29+
_endDate = State(initialValue: range.end)
3530
}
3631

3732
var body: some View {

LoopFollow/Stats/AggregatedStatsViewModel.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,8 @@ class AggregatedStatsViewModel: ObservableObject {
3939
}
4040

4141
func updatePeriod(_ days: Int, completion: @escaping () -> Void = {}) {
42-
let endDate = Date()
43-
let startDate = dateTimeUtils.displayCalendar().date(byAdding: .day, value: -days, to: endDate) ?? endDate
44-
updateDateRange(start: startDate, end: endDate, completion: completion)
42+
let range = StatsDateRange.lastComplete(days: days)
43+
updateDateRange(start: range.start, end: range.end, completion: completion)
4544
}
4645

4746
func updateDateRange(start: Date, end: Date, completion: @escaping () -> Void = {}) {

LoopFollow/Stats/DateRangePicker.swift

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,13 @@ struct DateRangePicker: View {
3131

3232
private var dayCount: Int {
3333
let calendar = dateTimeUtils.displayCalendar()
34-
return calendar.dateComponents([.day], from: startDate, to: endDate).day ?? 0
34+
let startDay = calendar.startOfDay(for: startDate)
35+
let endDay = calendar.startOfDay(for: endDate)
36+
return (calendar.dateComponents([.day], from: startDay, to: endDay).day ?? 0) + 1
3537
}
3638

3739
private var lastFullDay: Date {
38-
let calendar = dateTimeUtils.displayCalendar()
39-
let startOfToday = calendar.startOfDay(for: Date())
40-
return calendar.date(byAdding: .second, value: -1, to: startOfToday) ?? Date()
40+
StatsDateRange.lastComplete(days: 1).end
4141
}
4242

4343
var body: some View {
@@ -238,11 +238,9 @@ struct DateRangePicker: View {
238238
}
239239

240240
private func setDateRange(days: Int) {
241-
let calendar = dateTimeUtils.displayCalendar()
242-
endDate = lastFullDay
243-
let endDayStart = calendar.startOfDay(for: endDate)
244-
let startDayStart = calendar.date(byAdding: .day, value: -days, to: endDayStart) ?? endDayStart
245-
startDate = calendar.startOfDay(for: startDayStart)
241+
let range = StatsDateRange.lastComplete(days: days)
242+
startDate = range.start
243+
endDate = range.end
246244
showStartDatePicker = false
247245
showEndDatePicker = false
248246
onDateChange()

LoopFollow/Stats/SimpleStatsViewModel.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ class SimpleStatsViewModel: ObservableObject {
6767
let smbTotal = smbInPeriod.reduce(0.0) { $0 + $1.value }
6868
let totalBolusInPeriod = bolusTotal + smbTotal
6969

70-
let cutoffTime = Date().timeIntervalSince1970 - (Double(dataService.daysToAnalyze) * 24 * 60 * 60)
70+
let cutoffTime = dataService.startDate.timeIntervalSince1970
7171
let allBolusDates = (bolusesInPeriod + smbInPeriod).map { $0.date }.filter { $0 >= cutoffTime }
7272
let actualDays = calculateActualDaysCovered(dates: allBolusDates, requestedDays: dataService.daysToAnalyze)
7373

@@ -94,7 +94,7 @@ class SimpleStatsViewModel: ObservableObject {
9494

9595
let totalCarbsInPeriod = dailyCarbs.values.reduce(0.0, +)
9696

97-
let daysWithData = max(dailyCarbs.count, 1)
97+
let daysWithData = dataService.daysToAnalyze
9898

9999
if daysWithData > 0 {
100100
avgCarbs = totalCarbsInPeriod / Double(daysWithData)
@@ -296,7 +296,7 @@ class SimpleStatsViewModel: ObservableObject {
296296
guard !dates.isEmpty else { return requestedDays }
297297

298298
let calendar = dateTimeUtils.displayCalendar()
299-
let cutoffTime = Date().timeIntervalSince1970 - (Double(requestedDays) * 24 * 60 * 60)
299+
let cutoffTime = dataService.startDate.timeIntervalSince1970
300300
let filteredDates = dates.filter { $0 >= cutoffTime }
301301

302302
var uniqueDays = Set<Date>()

LoopFollow/Stats/StatsDataService.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@ class StatsDataService {
3232
func updateDateRange(start: Date, end: Date) {
3333
startDate = start
3434
endDate = end
35-
// Also update daysToAnalyze for compatibility with existing code
36-
let daysBetween = dateTimeUtils.displayCalendar().dateComponents([.day], from: start, to: end).day ?? 14
37-
daysToAnalyze = max(daysBetween, 1)
35+
let calendar = dateTimeUtils.displayCalendar()
36+
let startDay = calendar.startOfDay(for: start)
37+
let endDay = calendar.startOfDay(for: end)
38+
let daysBetween = calendar.dateComponents([.day], from: startDay, to: endDay).day ?? 13
39+
daysToAnalyze = daysBetween + 1
3840
}
3941

4042
func ensureDataAvailable(onProgress: @escaping () -> Void, completion: @escaping () -> Void) {
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// LoopFollow
2+
// StatsDateRange.swift
3+
4+
import Foundation
5+
6+
enum StatsDateRange {
7+
/// Returns start/end dates for the last complete N-day period.
8+
/// End is 23:59:59 of yesterday; start is 00:00:00 of the day N days back.
9+
static func lastComplete(days: Int) -> (start: Date, end: Date) {
10+
let calendar = dateTimeUtils.displayCalendar()
11+
let startOfToday = calendar.startOfDay(for: Date())
12+
let end = calendar.date(byAdding: .second, value: -1, to: startOfToday) ?? Date()
13+
let endDayStart = calendar.startOfDay(for: end)
14+
let start = calendar.date(byAdding: .day, value: -(days - 1), to: endDayStart) ?? endDayStart
15+
return (start: calendar.startOfDay(for: start), end: end)
16+
}
17+
}

0 commit comments

Comments
 (0)