День — это не 24 часа, а дата — это не момент времени


Я поддерживаю приложение для тех, кто живёт в Сербии по безвизу, и один его экран отвечает на вопрос с реальными последствиями: сколько дней у меня осталось и какой день — последний, когда я имею право здесь находиться?
Ошибись в пользу пользователя — и человек получит оверстей. Оверстей — это не штраф, на который машут рукой: это запись, штраф и потенциально запрет на въезд, измеряемый годами, и решает это человек на границе, которому неинтересно, как у тебя там округлилось. Это калькулятор, в котором ошибка на единицу — юридическое событие.
Он поехал в прод с ошибкой на единицу. Вот тест, который её теперь держит, из srbguide:
test('the old truncation bug: two days left is not reported as zero', () {
// Subtracting DateTimes and reading .inDays used to return 0 here.
final VisaStatus s = calculateVisaStatus(
rule: rule,
stays: <Stay>[Stay(entry: d(2026, 1, 1))],
today: DateTime(2026, 1, 29, 10),
);
expect(s.daysLeft, 2, reason: '29th and 30th are both still available');
expect(s.isOverstay, isFalse);
});
В десять утра 29 января, при дедлайне 30 января, приложение показывало ноль дней. Их было два. Оно предлагало людям покинуть страну в день, в который они имели полное право в ней находиться.
Починка маленькая. Причина — нет, и она обобщается далеко за пределы виз:
Дата — это не точка во времени, а день — это не 24 часа. Любой баг с датами, который я писал, был одним из этих двух предложений, оказавшимся ложью где-то в коде.
Почти каждый язык выдаёт тебе один тип на оба понятия, и вот отсюда всё начинается.
Баг 1: .inDays отвечает не на тот вопрос, который задали
В исходном коде было то, что пишут все:
final int daysLeft = deadline.difference(DateTime.now()).inDays;
Прочитай вслух — звучит ровно как вопрос. Это не он.
difference возвращает Duration — физический интервал между двумя моментами. .inDays делит этот интервал на 24 часа и отбрасывает дробную часть. В 10:00 29 января до полуночи, начинающей 30 января, 38 часов. 38 делить на 24 — это 1,58. Отброшено: 1. А поскольку код вычитал полночь дедлайна из текущего момента, а не из текущего дня, до экрана доезжал ноль.
Число было неверным не из-за погрешности. Это был ответ на секундомерный вопрос — сколько времени должно пройти — поданный как ответ на календарный: сколько ещё дней мне можно здесь быть. Это разные величины, и совпадают они ровно в полночь.
Форма ровно та же, что у бага, про который я уже писал на SQL, где оконная функция выглядит как колонка, а является сортировкой: значение читается как одна сущность, а ведёт себя как другая, и баг — в прочтении, а не в арифметике.
Правильный примитив вообще не связывается с длительностями:
/// Whole days from [a] to [b], both dates inclusive of their own day.
int daysBetween(DateTime a, DateTime b) =>
dateOnly(b).difference(dateOnly(a)).inDays;
difference на месте, но оба операнда сначала схлопнуты в полночь, поэтому интервал всегда целое число дней и отбрасывать уже нечего. Сначала нормализуй, потом вычитай. Никогда не наоборот.
Баг 2: dateOnly обязан возвращать UTC, и это не вкусовщина
Вот функция вместе с комментарием, объясняющим единственное решение в ней, которое имеет значение:
/// Strips the time component so day arithmetic is not thrown off by clock time.
///
/// Deliberately returns a **UTC** date. Serbia moves its clocks twice a year,
/// and `Duration(days: 1)` is exactly 24 hours — so adding days to a local
/// DateTime across the last Sunday in March shifts the wall clock by an hour
/// and can drop a whole day from a difference. Anchoring every calculation to
/// UTC midnight removes the problem; nothing here is a real instant in time,
/// only a calendar day.
DateTime dateOnly(DateTime d) => DateTime.utc(d.year, d.month, d.day);
Механизм, конкретно. В Сербии есть переход на летнее время. В последнее воскресенье марта часы прыгают с 02:00 на 03:00, и локальные сутки длятся 23 часа. Если твои даты локальные и ты прибавляешь Duration(days: 1) — а он определён как ровно 24 часа, во всех известных мне языках с типом длительности, — ты попадаешь на следующий день в 01:00, а не в полночь. Сделай так несколько раз или вычти через эту границу — и какой-нибудь .inDays отбросит целые сутки в небытие.
Раз в год, в одну сторону, в одной таймзоне. То есть: тестами не ловится, по багрепорту не воспроизводится и прилетает ровно одной когорте пользователей в конце марта.
У полуночи по UTC таких суток нет. Перехода нет, каждый день — 24 часа, арифметика замкнута. Последнюю часть комментария я бы подчеркнул: ничто здесь не является реальным моментом времени, только календарным днём. Мы храним не момент. Мы храним страницу календаря, и UTC используется как система координат без таймзоны, а не как таймзона.
А парная функция вообще не трогает Duration:
/// [d] shifted by [days] calendar days, DST-safe by construction.
DateTime addDays(DateTime d, int days) {
final DateTime base = dateOnly(d);
return DateTime.utc(base.year, base.month, base.day + days);
}
base.day + days может быть 47 или −3. Конструктор нормализует это в настоящую дату. Это правильный способ двигаться по календарным дням в большинстве date-API, и он известен куда хуже, чем заслуживает: отдай переполнение конструктору, не прибавляй длительность. Одно — календарная арифметика. Второе — физика.
Баг 3: считаются оба конца
Правило, которое реализует приложение, на этот счёт высказывается прямо, и это не выводится — это надо пойти и прочитать:
Считаются и день въезда, и день выезда. Въехал 1 января, выехал 2 января — это два дня пребывания.
Поэтому каждый подсчёт — это задача про столбы и пролёты:
final DateTime lastLawful = addDays(latest.entry, rule.allowanceDays - 1);
final int used = daysBetween(latest.entry, countedTo) + 1;
final int left = daysBetween(now, lastLawful) + 1;
Въехал 1 января с лимитом в 30 дней — последний законный день 30-е, а не 31-е. И - 1, и оба + 1 кодируют один и тот же факт с разных сторон, и каждое из этих мест — точка, где нормальный человек пишет очевидное и ошибается ровно на день.
Тесты, которые это удерживают, стоит выписать: их я бы скопировал в любой проект, работающий с диапазонами дат.
expect(s.mustLeaveBy, d(2026, 1, 30)); // день въезда считается
// …
test('the last lawful day still counts as one day left', () {
// today = 30 января, 23:00 — последний вечер
expect(s.daysLeft, 1);
expect(s.daysUsed, 30);
expect(s.isOverstay, isFalse);
});
// …
test('the day after the deadline is an overstay', () {
// today = 31 января
expect(s.daysLeft, 0);
expect(s.isOverstay, isTrue);
});
Три теста вокруг одной границы: день до, сам день в 23:00 и день после. Код с датами ошибается именно на границах, и «последний день, поздно вечером» — самый ценный тест-кейс во всём файле: там разом получают свой шанс и отбрасывание дробной части, и включение концов.
Баг 4: скользящее окно — это не арифметика, это множество
Вот здесь большинство визовых калькуляторов в интернете просто неверны, и здесь же задача перестаёт быть про типы дат и становится про то, что правило вообще говорит.
Не все живут по одному правилу. Приложение моделирует три:
perEntry30(allowanceDays: 30, windowDays: null), // RU, BY, CN, KZ — на въезд
rolling90in180(allowanceDays: 90, windowDays: 180), // ЕС, США, Украина и большинство
rolling30in365(allowanceDays: 30, windowDays: 365), // Багамы, Колумбия, Индонезия, …
Про первое у людей есть интуиция: 30 дней на въезд, выехал-въехал — счётчик обнулился. Это и есть визаран, и он работает — для этих гражданств.
Остальные два — скользящие окна, а скользящее окно не считает поездки. Оно считает дни присутствия внутри окна, которое едет. Выезд и возврат не обнуляют ровным счётом ничего. Тест говорит это прямым текстом:
test('leaving and returning does not reset a rolling window', () {
final VisaStatus withRun = calculateVisaStatus(
rule: rule,
stays: <Stay>[
Stay(entry: d(2026, 1, 1), exit: d(2026, 3, 1)),
Stay(entry: d(2026, 3, 2)),
],
today: d(2026, 3, 2),
);
// 60 days in the first stay + 1 today; a visa run buys nothing here.
expect(withRun.daysUsed, 61);
expect(withRun.daysLeft, 29);
});
Шестьдесят один день израсходован после пересечения границы, которое по другому правилу обнулило бы всё в ноль. Человек, действующий по неверной модели, промахивается на два месяца.
Поэтому реализация не вычисляет. Она считает:
int usedAt(DateTime ref, DateTime presentUntil) {
final DateTime from = addDays(ref, -(window - 1));
int count = 0;
for (int i = 0; i < window; i++) {
final DateTime day = addDays(from, i);
if (day.isAfter(ref)) break;
for (final Stay s in stays) {
if (s.covers(day, openEndsAt: presentUntil)) {
count++;
break;
}
}
}
return count;
}
Проход по 180 (или 365) дням по одному, с вопросом к каждому: я тут был? Это O(окно × поездки), это аллоцирует DateTime на день, и это правильная реализация.
Хочу её защитить, потому что инстинкт — заменить всё это арифметикой интервалов: обрезать каждую поездку по окну, просуммировать длины, вычесть пересечения. Такая версия быстрее, и живут баги именно в ней: смежные поездки, поездка, начавшаяся до окна и кончившаяся внутри, две поездки с общим концом (выехать и въехать можно в один день — в тестах ровно такой кейс есть, обе поездки покрывают этот день, и засчитан он должен быть один раз). break во внутреннем цикле и есть то, что засчитывает пересекающиеся поездки один раз, а в интервальной версии эта поправка — отдельный кусок логики, который надо не забыть написать.
Цикл же — прямая транскрипция юридического текста. Для каждого дня в окне: был ли ты здесь? Его можно отдать юристу. При 180 итерациях против горстки поездок, на экране, который человек открывает дважды в месяц, аргумента про производительность не существует.
Когда спецификация — это предикат на маленьком множестве, перебери множество. Брутфорс здесь не срезанный угол от лени, а единственная версия, про которую очевидно, что она и есть правило.
Баг 5: у вопроса «когда мне выезжать» нет формулы
Вот это меня удивило, и это по-настоящему интересное свойство скользящих окон.
По правилу «на въезд» ответ арифметический: въезд + 29. По скользящему окну — нет. Потому что по мере того, как сегодня сдвигается вперёд, сдвигается и левый край окна, и дни присутствия 180-дневной давности из него выпадают. Твой лимит растёт, пока ты сидишь на месте. У человека, у которого сегодня осталось 5 дней, через три недели может быть 20 — без всякого выезда, просто старая поездка состарилась и вышла из окна.
Формулы для этого проще самой задачи нет, поэтому код симулирует вперёд:

DateTime probe = now;
for (int i = 0; i <= window; i++) {
final DateTime candidate = addDays(now, i);
if (usedAt(candidate, candidate) > rule.allowanceDays) break;
probe = candidate;
}
lastLawful = probe;
Шаг вперёд по одному дню, пересчёт всего окна на каждом шаге в предположении продолжения присутствия, стоп на последнем дне, который не переполняет. usedAt внутри цикла по окну даёт O(окно²) в худшем случае — 32 400 итераций для 90/180, что ничто, и 133 225 для 30/365, что тоже ничто.
Этот раздел мне нравится тем, что это случай, где правильный ответ звучит как «это нельзя вычислить, это надо искать», и поиск занимает четыре строки. Довольно много логики с датами устроено так — если перестать настаивать на формуле.
Мина, которую я оставил, и у вас она, скорее всего, тоже
В том же приложении есть экран напоминаний, и повторяющиеся дедлайны перекатываются так:
if (kind.repeatsMonthly) {
final DateTime now = DateTime.now();
DateTime next = DateTime(now.year, now.month, date.day);
if (next.isBefore(DateTime(now.year, now.month, now.day))) {
next = DateTime(now.year, now.month + 1, date.day);
}
return next;
}
now.month + 1, дающий 13, — нормально: конструктор нормализует это в январь следующего года, ровно то поведение, на которое опирается addDays выше.
Ловушка — в date.day. Дедлайн 31-го числа, перекатываемый в 30-дневный месяц, нормализуется в 1-е число следующего. Он не падает и не зажимается. Он молча пропускает месяц.
В этом приложении так вышло безопасно: месячная периодичность — это паушальный налог, срок по нему 15-го, а 15-е есть в любом месяце. Это не дизайн, это везение, и в момент, когда пользователь заведёт свой месячный дедлайн на 31-е, это станет багом. Корректное поведение — решение, которое кто-то должен принять (зажать в последний день месяца или пропустить), а код сейчас принимает его случайно.
Я это включаю, потому что таково честное состояние файла и потому что это ровно та форма бага, которая переживает ревью: он неверен только в 7 месяцах из 12, только для 3 возможных значений дня и читается как совершенно обычный код.
Правило, которое связывает всё вместе
Ещё одна функция, выглядящая тривиально и являющаяся границей всего дизайна:
/// The same calendar day in the device's own time zone, at midnight.
///
/// Calendar days here are UTC-anchored, but anything leaving this file for the
/// platform — a calendar event, a scheduled notification — has to be a real
/// local instant.
DateTime localDay(DateTime d) => DateTime(d.year, d.month, d.day);
UTC внутри, локальное — на краю. Уведомление — реальный момент, оно обязано сработать в 9 утра там, где пользователь физически есть, а не в 9 утра UTC. Поэтому конвертация происходит ровно в одном месте, на выходе, и никогда — посреди вычисления.
Та же дисциплина, что с парсингом на границе или нормализацией кодировок на входе: выбери одно внутреннее представление, конвертируй только на краях и сделай край достаточно явным, чтобы у него было имя. Баги живут в файлах, где оба представления плавают вперемешку и никто не может сказать, что именно лежит в конкретной переменной.
Если сжать всю статью до переносимого:
Два разных понятия, один тип. Решай по каждой переменной, момент это или календарный день, и не давай им встречаться без именованной конвертации.
Сначала нормализуй, потом вычитай.
.inDaysна сырой разнице — почти всегда баг.Календарные дни двигаются через переполнение в конструкторе, а не прибавлением длительностей.
Пиши граничные тесты: день до, сам день поздно вечером, день после.
Когда спецификация — предикат на маленьком множестве, перебери его. Цикл, совпадающий с юридическим текстом, лучше формулы, которая в 200 раз быстрее и на 5% неверна.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.