Files
bot_vk_ikp_prodagi/docs/superpowers/specs/2026-09-05-reminders-pause-timezone-design.md
T
2026-09-05 14:29:01 +03:00

9.6 KiB
Raw Blame History

Напоминалки: пауза и часовой пояс — дизайн

Дата: 2026-09-05

Проблема

Фича «Напоминалки» (см. 2026-09-02-admin-reminders-design.md) уже работает, но не хватает двух вещей:

  1. Нельзя временно отключить напоминание без удаления (например, аренду продлили раньше срока, но напоминание пригодится в следующий раз).
  2. Дата/время вводятся и хранятся в серверном времени без учёта того, что настраивающий напоминание админ может физически находиться в другом часовом поясе — время срабатывания может «уехать» на несколько часов от того, что админ имел в виду.

Часовой пояс

Хранение

Новая таблица SQLite admin_settings:

CREATE TABLE IF NOT EXISTS admin_settings (
    user_id INTEGER PRIMARY KEY,
    tz_offset INTEGER NOT NULL,   -- смещение от UTC в часах, напр. 3 для Москвы
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)

Диапазон значений: −12..+14 (реальные часовые пояса Земли).

Гейт при входе в раздел

Любое обращение к разделу « Напоминалки» (просмотр списка, « Добавить», «🗑 Удалить», «⏸/▶ Пауза») сначала проверяет db.get_admin_tz_offset(user_id). Если пояс не задан — бот переводит админа в состояние ADMIN_REMINDER_TZ_SETUP и просит ввести смещение («Ваш часовой пояс относительно UTC, например +3 — Москва, +5 — Екатеринбург, 0 — UTC»). После сохранения бот показывает то, за чем админ изначально пришёл (список напоминаний) — без необходимости повторно жать исходную кнопку.

Пояс можно сменить позже в любой момент командой часовой пояс <±N>.

Конвертация при создании (вход → сервер)

Дата/время, введённые автором в шаге ADMIN_REMINDER_DATE, интерпретируются как его локальное время и переводятся в серверное перед сохранением:

server_dt = admin_dt + timedelta(hours=(server_utc_offset - author_tz_offset))

где server_utc_offset = datetime.now().astimezone().utcoffset().total_seconds() / 3600 (собственное смещение сервера от UTC, вычисляется на лету — часовой пояс сервера нигде явно не настроен и может быть любым).

remind_at/next_at в БД как хранили серверное время, так и хранят — планировщик (_check_due_reminders) и расчёт периодичности (compute_next_occurrence) не меняются вообще.

Отображение в общем списке (сервер → зритель)

Список напоминаний общий для всех админов. Время каждой строки пересчитывается под часовой пояс того, кто сейчас смотрит список (используя его собственный tz_offset, который уже гарантированно задан благодаря гейту):

viewer_dt = server_dt + timedelta(hours=(viewer_tz_offset - server_utc_offset))

Рядом с временем — пометка (UTC+N). В тексте раздела — короткая приписка: « Чтобы время в списке отображалось верно у всех, каждому нужно один раз зайти сюда и указать свой часовой пояс».

Важно: срабатывание напоминания НЕ зависит от того, установили ли получатели свой часовой пояс — момент отправки уже зафиксирован в серверном времени при создании. Часовой пояс получателей влияет только на то, как ОНИ видят время в списке, когда сами зайдут в раздел.

Общие функции (в reminders.py)

def server_utc_offset_hours() -> float:
    """Собственное смещение сервера от UTC в часах (для конвертации между
    часовым поясом админа и серверным временем хранения)."""

def convert_time(dt: datetime, from_offset: float, to_offset: float) -> datetime:
    """Переводит наивный datetime из одного часового пояса (смещение от UTC
    в часах) в другой."""

Пауза

Хранение

В таблицу reminders добавляется колонка:

ALTER TABLE reminders ADD COLUMN paused INTEGER NOT NULL DEFAULT 0

(миграция через try/except sqlite3.OperationalError, как другие миграции в database.py, чтобы не ломать уже существующую БД).

Логика

get_due_reminders добавляет условие AND paused = 0 — напоминание на паузе не попадает в выборку планировщика, но остаётся в get_active_reminders для отображения и управления.

Новые методы DatabaseManager: set_reminder_paused(reminder_id: int, paused: bool).

Возобновление не требует пересчёта дат — просто снимает флаг paused. Если время уже прошло, пока напоминание было на паузе, срабатывает то же правило, что и при простое бота (уже реализовано в Task 5): разовое — пришлёт один раз при ближайшей проверке и деактивируется; периодическое — пришлёт раз и само пересчитает следующее срабатывание в будущее (compute_next_occurrence уже умеет пропускать пропущенные периоды). Никакой новой логики для этого не требуется.

UI

Одна кнопка «⏸/▶ Пауза» в разделе «Напоминалки» (кнопка добавляется в get_reminders_admin_keyboard). Открывает список выбора (как удаление, через get_pick_keyboard), где подпись каждого пункта отражает текущее состояние:

  • активное → "⏸ Поставить на паузу: <текст>"
  • на паузе → "▶ Возобновить: <текст>"

Выбор пункта переключает флаг на противоположный. Новое состояние мастера: ADMIN_REMINDER_PAUSE_PICK.

В списке напоминаний (_send_reminders_admin) у напоминаний на паузе — пометка ⏸ (на паузе) перед текстом.

Изменяемые файлы

  • database.py — таблица admin_settings + CRUD (get_admin_tz_offset, set_admin_tz_offset); колонка paused в reminders + set_reminder_paused; фильтр paused = 0 в get_due_reminders.
  • reminders.pyserver_utc_offset_hours(), convert_time().
  • bot.py — гейт часового пояса перед разделом «Напоминалки», состояние ADMIN_REMINDER_TZ_SETUP, конвертация в ADMIN_REMINDER_DATE, пересчёт для отображения в _send_reminders_admin, команда часовой пояс <±N>, кнопка и состояние ADMIN_REMINDER_PAUSE_PICK.
  • keyboards.py — кнопка «⏸/▶ Пауза» в get_reminders_admin_keyboard.

Тестирование

  • tests/test_reminders.py: server_utc_offset_hours (проверка типа/разумного диапазона −12..14), convert_time для нескольких смещений и границы суток.
  • tests/test_database.py: CRUD admin_settings; paused — установка, фильтрация в get_due_reminders, попадание в get_active_reminders.
  • tests/test_flow.py: гейт часового пояса при первом входе в раздел (список не показывается, пока пояс не задан); конвертация введённой даты в серверное время при известном смещении; пауза/возобновление через мастер меняет фактическую отправку (_check_due_reminders не шлёт напоминание на паузе, шлёт после возобновления).