Logo Craft Homelab Docs Контакты Telegram
Даты и часовые пояса в Python: как правильно считать «сегодня» и «завтра» Трендовые github проекты в нашем телеграм канале. Подпишись →
1 сентября 2026 г.

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

Ввод задач одной строкой — удобный интерфейс: пользователь пишет Сдать отчёт завтра в 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)

Последовательность одна:

  1. определить дату в часовом поясе пользователя;
  2. собрать локальный datetime с этой датой и временем;
  3. перевести готовый момент в 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 в параметры делает каждый из этих сценариев обычным юнит-тестом: не нужно менять системные часы или ждать полуночи, чтобы воспроизвести граничный случай.