diff --git a/docs/superpowers/specs/2026-09-05-reminders-pause-timezone-design.md b/docs/superpowers/specs/2026-09-05-reminders-pause-timezone-design.md new file mode 100644 index 0000000..3806f3e --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-reminders-pause-timezone-design.md @@ -0,0 +1,161 @@ +# Напоминалки: пауза и часовой пояс — дизайн + +Дата: 2026-09-05 + +## Проблема + +Фича «Напоминалки» (см. [2026-09-02-admin-reminders-design.md](2026-09-02-admin-reminders-design.md)) +уже работает, но не хватает двух вещей: + +1. Нельзя временно отключить напоминание без удаления (например, аренду продлили + раньше срока, но напоминание пригодится в следующий раз). +2. Дата/время вводятся и хранятся в серверном времени без учёта того, что + настраивающий напоминание админ может физически находиться в другом часовом + поясе — время срабатывания может «уехать» на несколько часов от того, что + админ имел в виду. + +## Часовой пояс + +### Хранение + +Новая таблица SQLite `admin_settings`: + +```sql +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`) + +```python +def server_utc_offset_hours() -> float: + """Собственное смещение сервера от UTC в часах (для конвертации между + часовым поясом админа и серверным временем хранения).""" + +def convert_time(dt: datetime, from_offset: float, to_offset: float) -> datetime: + """Переводит наивный datetime из одного часового пояса (смещение от UTC + в часах) в другой.""" +``` + +## Пауза + +### Хранение + +В таблицу `reminders` добавляется колонка: + +```sql +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](../../../database.py) — таблица `admin_settings` + CRUD + (`get_admin_tz_offset`, `set_admin_tz_offset`); колонка `paused` в + `reminders` + `set_reminder_paused`; фильтр `paused = 0` в `get_due_reminders`. +- [reminders.py](../../../reminders.py) — `server_utc_offset_hours()`, + `convert_time()`. +- [bot.py](../../../bot.py) — гейт часового пояса перед разделом «Напоминалки», + состояние `ADMIN_REMINDER_TZ_SETUP`, конвертация в `ADMIN_REMINDER_DATE`, + пересчёт для отображения в `_send_reminders_admin`, команда + `часовой пояс <±N>`, кнопка и состояние `ADMIN_REMINDER_PAUSE_PICK`. +- [keyboards.py](../../../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` не шлёт напоминание на паузе, + шлёт после возобновления).