From fe28c8a395ae253f2a36fe54eb20271618baf87d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 9 Aug 2026 15:06:41 +0200 Subject: [PATCH] Step Dart dates by the calendar, not by 24 hours 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 lands on the wrong wall-clock day. Under Europe/Warsaw, spring-forward 2026-03-29: lastCompleteDay(2026-03-30) -> 2026-03-28 23:00, the day before yesterday, not yesterday weeksAgo: 1 from 2026-04-01 -> 2026-03-24 23:00, a Tuesday, so the wrong ISO week entirely a 03-27..03-31 period -> emits four day keys, silently dropping 2026-03-31 The Python half computes all three correctly, because timedelta on a date has no clock to drift against. So for about two weeks a year the phone and the PC would report different averages for the same log -- exactly the kind of device-local divergence the shared-source-of-truth rule exists to stop. Passing out-of-range components to DateTime's constructor normalizes on the calendar (day 0 is the previous month's last day), which is what timedelta already does. The same fix goes to day_status_service's streak walker: it had the identical pattern and predates this feature, so a streak that spanned the clock change could break when it should have held. None of the existing tests could see any of this -- every fixture date sits in a DST-free span -- so the regression tests use dates that straddle 2026-03-29 and assert on the calendar day rather than the DateTime, which keeps them meaningful whatever zone the runner is in. Verified they fail against the old arithmetic before fixing it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DjJqpcEsaV5WRhPXyxCThd --- app/lib/services/average_service.dart | 34 +++++++---- app/lib/services/day_status_service.dart | 13 ++++- app/test/services/average_service_test.dart | 65 +++++++++++++++++++++ 3 files changed, 98 insertions(+), 14 deletions(-) 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)); + }); + }); }