Трендовые github проекты в нашем телеграм канале. Подпишись → Почему «завтра» на сервере и у пользователя оказывается разными днями
Ввод задач одной строкой — удобный интерфейс: пользователь пишет Сдать отчёт завтра в 18:00 !!! @работа, а сервис сам вытаскивает из строки название, срок, приоритет и метки. Проблемы начинаются, когда в этой строке есть слова «сегодня», «завтра», «через месяц» или «каждый понедельник». Относительные даты завязаны на текущий момент, а текущий момент у пользователя и у сервера может приходиться на разные календарные дни.
Ниже — набор ошибок, которые всплывают в таком парсере, и способы их закрыть.
«Сегодня» зависит от часового пояса
Наивная реализация берёт дату прямо из серверных часов:
from datetime import datetime, timedelta, UTC
today = datetime.now(UTC).date()
tomorrow = today + timedelta(days=1)
Сервер работает в UTC, сроки хранятся в UTC, всё выглядит согласованно. Ошибка проявляется у полуночи.
Пусть в Москве уже 4 мая, 00:30. В этот момент в UTC ещё 3 мая, 21:30. Пользователь пишет «завтра» и подразумевает 5 мая. Сервер считает текущим днём 3 мая и получает 4 мая. Каждый расчёт по отдельности корректен, но они стартуют с разных суток.
Дату нужно брать в часовом поясе пользователя:
from zoneinfo import ZoneInfo
tz = ZoneInfo(timezone_name)
today = now.astimezone(tz).date()
tomorrow = today + timedelta(days=1)
Имя часового пояса знает браузер, его достаточно передать вместе с текстом задачи:
Intl.DateTimeFormat().resolvedOptions().timeZone // "Europe/Moscow"
Сначала местное время, потом UTC
Когда календарная дата определена, к ней добавляется указанное время. Здесь важен порядок операций:
from datetime import date, datetime, time, UTC
from zoneinfo import ZoneInfo
def at_local(day: date, hour: int, minute: int, timezone_name: str) -> datetime:
tz = ZoneInfo(timezone_name)
local = datetime.combine(day, time(hour, minute), tzinfo=tz)
return local.astimezone(UTC)
Последовательность одна:
- определить дату в часовом поясе пользователя;
- собрать локальный
datetimeс этой датой и временем; - перевести готовый момент в UTC для хранения.
Если начать с UTC, дата успевает сдвинуться раньше, чем код дойдёт до слова «завтра». datetime.combine с tzinfo и последующий astimezone(UTC) дают момент, который совпадает с тем, что пользователь видел на экране. Для зон с переходом на летнее время в Python 3.9+ ZoneInfo подберёт правильное смещение по дате.
Календарная дата и момент времени — разные типы данных
Фразы «до пятницы» и «в пятницу в 18:00» выглядят похоже, но описывают разные сущности. «До пятницы» — календарная дата без часов и без часового пояса. «В пятницу в 18:00» — конкретный момент на оси времени.
В новой схеме их стоит держать раздельно:
due_date: date | None
due_at: datetime | None
Главное правило: календарную дату без времени не переводят между часовыми поясами как обычный timestamp. Если схема уже существует и в ней есть только due_at, помогает флаг due_date_only: значение остаётся в прежнем поле, а флаг говорит слою отображения не применять смещение.
«Через месяц» — сдвиг календарного месяца
Прибавление 30 дней ломается на границах месяцев. К 31 января плюс 30 дней даёт 2 марта, хотя под «через месяц» обычно понимают конец февраля.
Корректный расчёт двигает номер месяца и ограничивает день последним числом получившегося месяца:
import calendar
from datetime import date
def add_month(day: date) -> date:
month = day.month % 12 + 1
year = day.year + (day.month == 12)
last_day = calendar.monthrange(year, month)[1]
return date(year, month, min(day.day, last_day))
31 января превращается в 28 февраля, а в високосный год — в 29 февраля.
Правилу повтора нужна первая дата
Фраза «тренировка каждый понедельник» создаёт правило weekly, но сама по себе не назначает ближайший понедельник. Без первой даты задача сохраняется «висящей», и после выполнения следующая не появляется.
days_ahead = (weekday - today.weekday()) % 7 or 7
first_due = today + timedelta(days=days_ahead)
Оператор or 7 нужен, чтобы «каждый понедельник», сказанное в понедельник, означало следующий понедельник, а не сегодняшний день.
Метки и знаки препинания
Регулярное выражение @(\S+) захватывает всё до пробела, включая хвостовую запятую: из @магазин, купить молоко получается метка магазин,. Запятая уезжает в базу как часть имени.
Явный список разделителей убирает проблему:
label_pattern = r"(?<!\w)@([^\s,.;:!?]+)"
Ретроспективный (?<!\w) заодно отсекает адреса почты: в foo@bar.com знак @ стоит после буквы, и метка не создаётся.
Тест, который действительно ловит баг
Ключевое изменение находится вне регулярных выражений. Текущее время и часовой пояс передаются в парсер снаружи как аргументы, а не читаются из системных часов внутри:
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», метка и приоритет по отдельности проходят, ошибка появляется, когда они встречаются в одной строке рядом с полуночью.
Что проверить в своём коде
Если парсер понимает слова «сегодня», «завтра» и «через месяц», стоит прогнать хотя бы такие случаи:
- несколько минут до и после полуночи;
- разные часовые пояса пользователя и сервера;
- дату отдельно и дату вместе со временем;
- 29, 30 и 31 января при сдвиге на месяц;
- переход с декабря на январь;
- повторяющуюся задачу без первой даты;
- запятую или точку сразу после метки;
- адрес электронной почты со знаком
@.
Вынос now и timezone_name в параметры делает каждый из этих сценариев обычным юнит-тестом: не нужно менять системные часы или ждать полуночи, чтобы воспроизвести граничный случай.