Резиновая уточка расследует баг парсера на границе двух дней
Резиновая уточка расследует баг парсера на границе двух дней

В Doday я сделал быстрое добавление задач. Вы пишете всё одной строкой, а сервис сам пытается понять, где название, дата, время, приоритет и метки.

Например:

Сдать реферат завтра в 18:00 !!! @школа

Из этой строки должно получиться:

  • название Сдать реферат;

  • срок — завтра в 18:00;

  • высокий приоритет — это три восклицательных знака;

  • метка школа.

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

Сначала он работал не так:

title = "Сдать реферат в 18:00"
due_at = "2026-05-04T23:59:00+00:00"
priority = "p1"
labels = ["школа"]

Время осталось в названии, а срок почему-то превратился в конец дня по UTC.

Я добавил распознавание формата 18:00 и думал, что закончил. Потом проверил ту же фразу рядом с полуночью и дата сдвинулась. Тут стало понятно, что одной регуляркой дело не решить.

Почему «завтра» оказалось разным

Старая версия брала текущую дату прямо с сервера:

today = datetime.now(UTC).date()
tomorrow = today + timedelta(days=1)

На первый взгляд всё нормально. Время хранится в UTC, сервер тоже работает в UTC. Где ошибка?

Представим, что в Москве уже 4 мая, 00:30. В UTC в этот момент ещё 3 мая, 21:30. Пользователь пишет «завтра» и имеет в виду 5 мая. Сервер считает, что сегодня ещё 3 мая, поэтому получает 4 мая.

Оба расчёта по отдельности правильные. Просто они начинаются с разных календарных дней.

Исправление получилось небольшим:

timezone = ZoneInfo(timezone_name)
today = now.astimezone(timezone).date()

Сначала я перевожу текущее время в часовой пояс пользователя и только потом беру дату. Тогда «сегодня» и «завтра» считаются так же, как их понимает человек перед экраном.

Имя часового пояса браузер знает сам:

Intl.DateTimeFormat().resolvedOptions().timeZone

Например, для Москвы он вернёт Europe/Moscow. Браузер отправляет это значение вместе с текстом задачи.

Сначала местное время, потом UTC

Когда дата уже найдена, к ней можно добавить указанное время:

def at_local(day: date, hour: int, minute: int, timezone_name: str) -> datetime:
    timezone = ZoneInfo(timezone_name)
    local = datetime.combine(day, time(hour, minute), tzinfo=timezone)
    return local.astimezone(UTC)

Здесь важен порядок:

  1. определить дату в часовом поясе пользователя;

  2. добавить местное время;

  3. перевести готовый момент в UTC для хранения.

Если начать с UTC, дата может сдвинуться ещё до того, как программа дойдёт до слова «завтра».

Тест, который действительно проверяет проблему

Я зафиксировал время около полуночи, чтобы тест не зависел от часов компьютера:

def test_tomorrow_uses_users_timezone() -> None:
    # 3 мая, 21:30 UTC = 4 мая, 00:30 в Москве.
    now = datetime(2026, 5, 3, 21, 30, tzinfo=UTC)

    parsed = parse_quick_add(
        "Сдать реферат завтра в 18:00 !!! @школа",
        now=now,
        timezone_name="Europe/Moscow",
    )

    assert parsed.title == "Сдать реферат"
    assert parsed.due_at == datetime(2026, 5, 5, 15, 0, tzinfo=UTC)
    assert parsed.priority == TaskPriority.P1
    assert parsed.label_names == ["школа"]

В Москве уже 4 мая, значит завтра - 5 мая. А 18:00 по Москве хранится как 15:00 UTC.

Мне здесь важен именно один общий тест. Если отдельно проверить слово завтра, отдельно время 18:00, а потом метку и приоритет, каждый кусок может пройти. Ошибка появляется только тогда, когда они встречаются в одной строке.

Дата без времени - это не то же самое

Пока исправлял этот баг, я заметил ещё одну проблему. Фразы до пятницы и в пятницу в 18:00 выглядят похоже, но означают разные данные.

До пятницы — это календарная дата. У неё нет часов и часового пояса. В пятницу в 18:00 — конкретный момент времени.

Если вы только проектируете базу, лучше хранить их отдельно:

due_date: date | None
due_at: datetime | None

В Doday схема уже существовала, поэтому я оставил due_at и добавил флаг due_date_only. Это не самый красивый вариант, зато он не ломает старые задачи. Главное — не переводить дату без времени между часовыми поясами как обычный timestamp.

Вместе с этим нашлись ещё три ошибки

На этом можно было остановиться, но я прогнал другие обычные фразы. Нашлись ещё три случая.

«Через месяц» - это не плюс 30 дней

Если прибавить 30 дней к 31 января, получится 2 марта. Но под фразой «через месяц» обычно имеют в виду последний день февраля.

Поэтому парсер теперь двигает календарный месяц и ограничивает день концом получившегося месяца. 31 января превращается в 28 февраля, а в високосный год — в 29 февраля.

Повтору нужна первая дата

Фраза тренировка каждый понедельник создавала правило weekly, но не назначала первый понедельник. Задача сохранялась без даты, и после выполнения следующая не появлялась.

days_ahead = (weekday - today.weekday()) % 7 or 7
first_due = today + timedelta(days=days_ahead)

Теперь сохраняются и правило повтора, и дата первого запуска.

Запятая не должна становиться частью метки

Регулярное выражение @(\S+) превращало @магазин, в метку магазин,. Запятая сохранялась в базе как часть названия.

Я заменил его на явный список разделителей:

label_pattern = r"(?<!\w)@([^\s,.;:!?]+)"

Заодно добавил проверку для адресов почты: foo@bar.com не должен превращаться в метку.

Как Mini App разбирает название, время, приоритет и метку
Как Mini App разбирает название, время, приоритет и метку

До сохранения видно, что именно программа нашла в строке.

Что стоит проверить у себя

Если ваша программа понимает слова сегодня, завтра или через месяц, попробуйте хотя бы такие случаи:

  • несколько минут до и после полуночи;

  • разные часовые пояса пользователя и сервера;

  • дату отдельно и дату вместе со временем;

  • 29, 30 и 31 января;

  • переход с декабря на январь;

  • повторяющуюся задачу без первой даты;

  • запятую или точку после метки;

  • адрес электронной почты со знаком @.

Самое полезное изменение оказалось не в регулярных выражениях. Я передаю текущее время и часовой пояс в парсер снаружи. Поэтому любой странный случай можно повторить обычным тестом, не меняя часы системы и не ожидая полуночи.

Код проекта:

https://github.com/SwairIt/doday

Код и эту статью я готовил в паре с ИИ-агентом. Он помог собрать граничные случаи и проверить формулировки, а итоговое решение я проверил на коде проекта и тестах.

Ярослав Боев.

Спасибо, что дочитали