mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:07:15 +01:00
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjJqpcEsaV5WRhPXyxCThd
This commit is contained in:
parent
e07284d3e1
commit
fe28c8a395
@ -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')}-'
|
||||
|
||||
@ -112,6 +112,15 @@ Map<String, DayStatus> 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;
|
||||
}
|
||||
|
||||
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user