diff --git a/app/lib/services/average_service.dart b/app/lib/services/average_service.dart index dbc224e..3d06ff8 100644 --- a/app/lib/services/average_service.dart +++ b/app/lib/services/average_service.dart @@ -48,20 +48,20 @@ AverageBand bandFor(double avgKcal, double avgBudget) { } /// Returns the last day whose log is finished: the day before [today]. -DateTime lastCompleteDay(DateTime today) => - _dateOnly(today).subtract(const Duration(days: 1)); +DateTime lastCompleteDay(DateTime today) => _addDays(today, -1); /// Returns the Monday and Sunday of the ISO week containing [day]. (DateTime, DateTime) weekBounds(DateTime day) { - final d = _dateOnly(day); - final monday = d.subtract(Duration(days: d.weekday - DateTime.monday)); - return (monday, monday.add(const Duration(days: 6))); + final monday = _addDays(day, DateTime.monday - day.weekday); + return (monday, _addDays(monday, 6)); } /// Returns the first and last dates of [day]'s calendar month. (DateTime, DateTime) monthBounds(DateTime day) => ( DateTime(day.year, day.month), - DateTime(day.year, day.month + 1).subtract(const Duration(days: 1)), + // Day 0 of the *next* month is the last day of this one, resolved by + // DateTime's constructor rather than by subtracting 24 hours. + DateTime(day.year, day.month + 1, 0), ); /// Returns the first of the month [months] before [day]'s month. @@ -83,11 +83,7 @@ PeriodAverage periodAverage( var totalBudget = 0; var loggedDays = 0; var elapsedDays = 0; - for ( - var day = from; - !day.isAfter(to); - day = day.add(const Duration(days: 1)) - ) { + for (var day = from; !day.isAfter(to); day = _addDays(day, 1)) { elapsedDays++; final key = dateKey(day); final entries = (log[key] ?? const []).where((e) => !e.deleted); @@ -131,7 +127,7 @@ PeriodAverage weeklyAverage( DateTime? today, }) { final ref = _dateOnly(today ?? DateTime.now()); - final anchor = ref.subtract(Duration(days: DateTime.daysPerWeek * weeksAgo)); + final anchor = _addDays(ref, -DateTime.daysPerWeek * weeksAgo); return _capped(log, schedule, weekBounds(anchor), ref); } @@ -168,6 +164,20 @@ PeriodAverage _capped( DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day); +/// Returns midnight [days] calendar days from [d], safely across DST. +/// +/// Deliberately NOT `d.add(Duration(days: days))`: a [Duration] is an absolute +/// span, so on a local [DateTime] it moves by 23 or 25 hours across a DST +/// boundary and lands on the wrong wall-clock day. In Europe/Warsaw that made +/// `lastCompleteDay(2026-03-30)` return the day *before* yesterday, put the +/// `weeksAgo` anchor in the wrong ISO week, and silently drop the last day of +/// any period spanning spring-forward -- so the phone and the PC disagreed +/// about the same log for two weeks a year. Passing out-of-range components +/// to the constructor normalizes on the *calendar* instead, which is what +/// Python's `timedelta` on a `date` already does. +DateTime _addDays(DateTime d, int days) => + DateTime(d.year, d.month, d.day + days); + /// Formats [d] as a `YYYY-MM-DD` log key. String dateKey(DateTime d) => '${d.year.toString().padLeft(4, '0')}-' diff --git a/app/lib/services/day_status_service.dart b/app/lib/services/day_status_service.dart index e72ed43..72b5d45 100644 --- a/app/lib/services/day_status_service.dart +++ b/app/lib/services/day_status_service.dart @@ -112,6 +112,15 @@ Map statusMap( DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day); +/// Returns midnight [days] calendar days from [d], safely across DST. +/// +/// See the note on `_addDays` in `average_service.dart`: a [Duration] is an +/// absolute span, so stepping a local [DateTime] by `Duration(days: 1)` moves +/// 23 or 25 hours across a DST boundary and yields the wrong [_dateKey] -- +/// which here would break a streak that should have held. +DateTime _addDays(DateTime d, int days) => + DateTime(d.year, d.month, d.day + days); + String _dateKey(DateTime d) { final y = d.year.toString().padLeft(4, '0'); final m = d.month.toString().padLeft(2, '0'); @@ -126,12 +135,12 @@ int _streak( }) { var day = _dateOnly(today ?? DateTime.now()); if (statusMap[_dateKey(day)] == null) { - day = day.subtract(const Duration(days: 1)); + day = _addDays(day, -1); } var count = 0; while (keeps.contains(statusMap[_dateKey(day)])) { count++; - day = day.subtract(const Duration(days: 1)); + day = _addDays(day, -1); } return count; } diff --git a/app/test/services/average_service_test.dart b/app/test/services/average_service_test.dart index cd9c326..742a8ff 100644 --- a/app/test/services/average_service_test.dart +++ b/app/test/services/average_service_test.dart @@ -231,4 +231,69 @@ void main() { expect(result.band, AverageBand.veryOver); }); }); + + // Every other fixture in this file sits in a DST-free span, so none of them + // can see the bug these cover: stepping a local DateTime by + // Duration(days: 1) moves 23 or 25 hours across a DST boundary and lands on + // the wrong wall-clock day. Under TZ=Europe/Warsaw (spring-forward + // 2026-03-29) that made the phone and the PC report different averages for + // the same log. Assertions are on the calendar day, never on the DateTime, + // so they mean the same thing whatever zone the runner is in. + group('date arithmetic across a DST boundary', () { + test('lastCompleteDay is yesterday even across spring-forward', () { + final result = lastCompleteDay(DateTime(2026, 3, 30)); + expect( + (result.year, result.month, result.day), + (2026, 3, 29), + reason: 'Duration-based arithmetic returned 2026-03-28 23:00 here', + ); + }); + + test('a period spanning spring-forward keeps every day', () { + final result = periodAverage( + logOf({ + '2026-03-27': 2000, + '2026-03-28': 2000, + '2026-03-29': 2000, + '2026-03-30': 2000, + '2026-03-31': 2000, + }), + schedule: flat(2000), + start: DateTime(2026, 3, 27), + end: DateTime(2026, 3, 31), + ); + expect(result.elapsedDays, 5); + expect( + result.loggedDays, + 5, + reason: 'the day-stepping loop used to drop 2026-03-31', + ); + }); + + test('the weeksAgo anchor stays in the right ISO week', () { + // 2026-04-01 is a Wednesday; one week back is the Mon 03-23..Sun 03-29 + // week, the one containing the clock change. + final result = weeklyAverage( + logOf({'2026-03-23': 2000, '2026-03-29': 2000}), + schedule: flat(2000), + weeksAgo: 1, + today: DateTime(2026, 4), + ); + expect(result.start, '2026-03-23'); + expect(result.end, '2026-03-29'); + expect(result.loggedDays, 2); + }); + + test('weekBounds spans Monday to Sunday across the change', () { + final (monday, sunday) = weekBounds(DateTime(2026, 3, 29)); + expect((monday.year, monday.month, monday.day), (2026, 3, 23)); + expect((sunday.year, sunday.month, sunday.day), (2026, 3, 29)); + }); + + test('monthBounds ends on the real last day of a DST month', () { + final (first, last) = monthBounds(DateTime(2026, 3, 15)); + expect((first.year, first.month, first.day), (2026, 3, 1)); + expect((last.year, last.month, last.day), (2026, 3, 31)); + }); + }); }