ESPN'A bit of an obsession': Inside Jaguars die-hards' pub takeover in LondonInquirerHeavy rains expected in NCR, other Luzon areas Sunday afternoonPunchNDLEA intercepts Saudi-bound cannabis in palm oil keg, ₦6.98bn tramadolThe Jerusalem PostNetanyahu: Magal's 'monkey' remark on Eisenkot inappropriate, but Likud voters face daily insultsDaily MaverickMANIFESTO TEST: PA promises debt write-offs and a 3-month rates holidayUOLBlue Origin investe para expandir lançamentos na FlóridaSky TG24I titoli di Sky TG24 del 18 ottobre: edizione delle 13ZDF heuteEntdecken Sie das ZDF-NachrichtenstudioBBC NewsEnding corridor care in hospitals for children a priority, says health secretarySportstarPAK vs SL LIVE score, Tri-Nation series: Pakistan posts 305 after Agha century; Real-time updates, commentaryعالم التقنيةجهاز Honor WIN اللوحي يقتحم سوق الألعاب بشاشة خارقة بتردد 240 هرتزANSAScontri ai cortei, Meloni: "Siamo con le forze dell'ordine". Piantedosi: "Azioni da professionisti del disordine"
The Daily Newsstand · Free, Always
Sunday, October 18, 2026

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

Translate
День — это не 24 часа. Дата — это не момент. Любой баг с датами — это одно из двух, оказавшееся ложью в коде

День — это не 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. Поэтому конвертация происходит ровно в одном месте, на выходе, и никогда — посреди вычисления.

Та же дисциплина, что с парсингом на границе или нормализацией кодировок на входе: выбери одно внутреннее представление, конвертируй только на краях и сделай край достаточно явным, чтобы у него было имя. Баги живут в файлах, где оба представления плавают вперемешку и никто не может сказать, что именно лежит в конкретной переменной.

Если сжать всю статью до переносимого:

  1. Два разных понятия, один тип. Решай по каждой переменной, момент это или календарный день, и не давай им встречаться без именованной конвертации.

  2. Сначала нормализуй, потом вычитай. .inDays на сырой разнице — почти всегда баг.

  3. Календарные дни двигаются через переполнение в конструкторе, а не прибавлением длительностей.

  4. Пиши граничные тесты: день до, сам день поздно вечером, день после.

  5. Когда спецификация — предикат на маленьком множестве, перебери его. Цикл, совпадающий с юридическим текстом, лучше формулы, которая в 200 раз быстрее и на 5% неверна.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.