
В 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)
Здесь важен порядок:
определить дату в часовом поясе пользователя;
добавить местное время;
перевести готовый момент в 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 не должен превращаться в метку.

До сохранения видно, что именно программа нашла в строке.
Что стоит проверить у себя
Если ваша программа понимает слова сегодня, завтра или через месяц, попробуйте хотя бы такие случаи:
несколько минут до и после полуночи;
разные часовые пояса пользователя и сервера;
дату отдельно и дату вместе со временем;
29, 30 и 31 января;
переход с декабря на январь;
повторяющуюся задачу без первой даты;
запятую или точку после метки;
адрес электронной почты со знаком
@.
Самое полезное изменение оказалось не в регулярных выражениях. Я передаю текущее время и часовой пояс в парсер снаружи. Поэтому любой странный случай можно повторить обычным тестом, не меняя часы системы и не ожидая полуночи.
Код проекта:
https://github.com/SwairIt/doday
Код и эту статью я готовил в паре с ИИ-агентом. Он помог собрать граничные случаи и проверить формулировки, а итоговое решение я проверил на коде проекта и тестах.
Ярослав Боев.
Спасибо, что дочитали

