4429b99a55
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
162 lines
9.6 KiB
Markdown
162 lines
9.6 KiB
Markdown
# Напоминалки: пауза и часовой пояс — дизайн
|
||
|
||
Дата: 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` не шлёт напоминание на паузе,
|
||
шлёт после возобновления).
|