---
project: vibe-coding
type: plan
date: 2026-05-08
status: implemented
spec: "{vibe-coding} {plan} tasks-bot obsidian – 2026-04-29.md"
plan_part: "3.1 of 3"
depends_on: "{vibe-coding} {plan} tasks-bot план-1 pi-core – 2026-04-30.md"
---

# Tasks Bot — Plan 3.1: Расписание сводок и базовые команды

**Goal:** Дополнить Pi-демон расписанием сводок (утро/вечер/воскресенье) и командами `/today`, `/overdue`, `/done`, плюс кнопкой «Перенести всё» в вечерней сводке. После этого этапа задачи можно не только захватывать, но и видеть актуальный список и закрывать сделанные.

**Architecture:** Два новых модуля в `src/tasks_bot/` — `summaries.py` (чистые builder-функции для текстов сводок) и `scheduler.py` (asyncio-воркер, считает время до ближайшей сводки, шлёт догон при пропуске). Новые command-handlers в `bot.py`. Расширение `db.py` query-методами для выборки по дедлайнам.

**Tech Stack:** Python 3.11+, существующий проект `tasks_bot`, pytest. Никаких новых зависимостей — расписание на чистом asyncio.

**Что отложено в Plan 3.2:** Команды воскресного управления `/this`, `/skip`, `/del`. Воскресный обзор в Plan 3.1 идёт read-only — просто список беклога без actionable-команд.

---

## Контракт с Plan 1

- БД-таблица `last_summary` уже создана в Plan 1, методы `save_summary`, `load_summary`, `load_latest_summary` уже реализованы — переиспользуем.
- `now_tz(tz)` и `today_tz(tz)` импортируем из `tasks_bot.clock`.
- `Database.fetch_for_json` возвращает все активные задачи — но для сводок нужны более узкие выборки (по дедлайну). Добавляем новые методы, не трогая существующие.
- В `pyproject.toml` ничего не добавляем.

---

## Структура проекта

```
projects/vibe-coding/tasks-bot/
├── src/tasks_bot/
│   ├── ... (модули Плана 1, без изменений кроме перечисленных)
│   ├── summaries.py                ← новый
│   ├── scheduler.py                ← новый
│   ├── bot.py                      ← расширяется (новые handlers)
│   ├── db.py                       ← расширяется (новые query-методы)
│   └── main.py                     ← расширяется (запуск scheduler)
├── tests/
│   ├── test_summaries.py           ← новый
│   ├── test_scheduler.py           ← новый
│   ├── test_bot_commands.py        ← новый
│   └── test_db_queries.py          ← новый
```

---

## Поведение команд и сводок

### Сводки

| Тип | Расписание | Что показывает | Сохраняет |
|-----|-----------|---------------|-----------|
| Утренняя | 9:00 Asia/Yekaterinburg | 3 секции: На сегодня (deadline = today), На этой неделе (deadline в текущей неделе, не сегодня), Без срока | `last_summary[morning]` |
| Вечерняя | 21:00 | Закрыто за день (счётчик и список), осталось на сегодня (нумерованные) + кнопка «Перенести всё» | `last_summary[evening]` |
| Воскресная | Вс 18:00 | Просроченные + Без срока (read-only список) | `last_summary[weekly]` |

«Сегодня» считается через `today_tz('Asia/Yekaterinburg')`. «Эта неделя» — от сегодня до ближайшего воскресенья включительно (используется `nearest_sunday_inclusive(today)`).

### Команды

| Команда | Поведение |
|---------|-----------|
| `/today` | Пересчитывает «утреннюю» сводку на сейчас, отправляет, сохраняет `last_summary[manual]` |
| `/overdue` | Список просроченных (deadline < today, status=confirmed) с нумерацией. Сохраняет `last_summary[overdue]`. Если просроченных нет — «Просроченных нет ✨» |
| `/done N` или `/done N,M,K` | Резолвит номера через **самый свежий** `last_summary` по `created_at`. Закрывает задачи (`status=done`, `done_at=now`). Если ни одной сводки не было — «Сначала вызови /today чтобы получить нумерацию» |

### Кнопка «Перенести всё»

Под вечерней сводкой. По нажатию: все активные задачи с дедлайном «сегодня» получают дедлайн «завтра» и `deadline_kind=date`. Бот редактирует сообщение: «Перенесла N задач на завтра». При повторном нажатии после переноса — «Уже перенесено».

### Регистрация команд в Telegram

В `main.py` после `app.start()` вызывается `app.bot.set_my_commands([...])` со списком всех команд. Это даёт меню-подсказку слева от поля ввода.

---

## Edge cases

**Догон при простое Pi:**
- На старте scheduler проверяет: для каждого типа сводки (`morning`, `evening`, `weekly`) — было ли уже отправлено сегодня (или в это воскресенье для weekly).
- Сравнение: `last_summary[type].created_at` берётся, проверяется попадает ли в текущий «период» (день для morning/evening, воскресенье для weekly). Если нет — и текущее время уже прошло запланированную точку — догон.
- Воскресный обзор не догоняем, если уже понедельник.

**Команды без свежего mapping:**
- `/done 1` без `last_summary` → подсказка «Сначала вызови /today».
- `/done 99` (вне маппинга) → «В последней сводке нет номера 99».
- `/done 1` где задача уже `done` → отдельный список «Уже было закрыто: …».
- `/done 1,2,99` → закрываем 1 и 2, отдельно перечисляем 99 как «не найдено».
- `/done` без аргументов → подсказка «Используй: /done N или /done N,M».

**Telegram временно недоступен:**
- Сводка не отправилась — логируем ошибку, **`last_summary` не сохраняем**. Следующая запланированная сводка попробует снова. Не делаем ретрай в MVP.

**Кнопка «Перенести всё» нажата после переноса:**
- Хендлер проверяет: есть ли задачи со статусом `confirmed` и дедлайном «сегодня». Если нет — отвечает «Уже перенесено» и убирает кнопку.

**Часовой пояс:**
- Все события строго через `now_tz('Asia/Yekaterinburg')` и `today_tz('Asia/Yekaterinburg')`. UTC не используется нигде.

---

## Модуль `summaries.py`

Чистые функции, без побочных эффектов и без БД. Входы — список задач (модель `Task`) и сегодняшняя дата. Выходы — `(text, numbers_to_ids)`.

```python
from dataclasses import dataclass
from datetime import date
from .models import Task

@dataclass
class Summary:
    text: str
    numbers_to_ids: dict[int, int]  # {номер_в_сводке: id_задачи}

def build_morning(tasks: list[Task], today: date) -> Summary: ...
def build_evening(active: list[Task], done_today: list[Task], today: date) -> Summary: ...
def build_weekly(tasks: list[Task], today: date) -> Summary: ...
def build_overdue(tasks: list[Task], today: date) -> Summary: ...
```

**Принцип нумерации:** номер растёт по всему сообщению через секции, не сбрасывается. Это позволяет `/done 1,3,5` ссылаться на разные секции одного сообщения.

**Формат строки задачи в сводке:**
```
{N}. {текст} — {label_проекта}
```

`label_проекта` — `Марс`/`Канал`/`Консалтинг`/`Жизнь`/`Входящие` (из `PROJECT_LABELS` в `bot.py`, переезжает в `summaries.py` или общий модуль).

---

## Модуль `scheduler.py`

Один класс `SummaryScheduler` с методом `run()` (как `SyncWorker`).

```python
class SummaryScheduler:
    def __init__(self, db: Database, bot, owner_id: int, tz: str): ...
    async def run(self) -> None:
        # На старте: проверить пропущенные сводки и догнать
        await self._catchup_if_missed()
        # Основной цикл: спим до ближайшей точки, шлём, повторяем
        while not self._stopped:
            next_event = self._compute_next_event(now_tz(self.tz))
            await asyncio.sleep_until(next_event.when)
            await self._send_summary(next_event.type)
    def stop(self): self._stopped = True
```

**`_compute_next_event(now)`** — чистая функция: возвращает (тип, datetime) ближайшего события среди (сегодня 9:00, сегодня 21:00, ближайшее Вс 18:00). Если все три уже прошли сегодня — берёт завтра 9:00.

**`_catchup_if_missed()`** — на старте: для каждого типа сводки сравнивает `last_summary[type].created_at` с текущим периодом, если пропущена и время уже прошло — шлёт.

**`_send_summary(type)`** — собирает данные из БД, зовёт нужный builder из `summaries.py`, отправляет через `bot.send_message`, сохраняет `last_summary[type]`. На исключении логирует и не сохраняет.

`asyncio.sleep_until` нет в стандартной библиотеке — используем `asyncio.sleep((target - now).total_seconds())`.

---

## Расширение `db.py`

Новые методы (тонкие SELECT-обёртки):

```python
async def fetch_due_today(self, today: date) -> list[Task]: ...
async def fetch_due_this_week(self, today: date) -> list[Task]: ...
   # deadline в (today, nearest_sunday_inclusive(today)] — без сегодняшних
async def fetch_no_deadline(self) -> list[Task]: ...
async def fetch_overdue(self, today: date) -> list[Task]: ...
   # deadline < today, status = confirmed
async def fetch_done_today(self, today: date) -> list[Task]: ...
   # status = done, done_at попадает в today
async def defer_today_to_tomorrow(self, today: date, now) -> int: ...
   # для всех confirmed с deadline = today: ставит deadline = today+1, deadline_kind=date
   # возвращает количество перенесённых
```

Все возвращают `list[Task]` через `_row_to_task`. Все исключают `pending_confirmation` и `cancelled`.

---

## Расширение `bot.py`

Новые command-handlers и один callback-handler:

```python
async def handle_today_command(update, context): ...   # /today
async def handle_overdue_command(update, context): ... # /overdue
async def handle_done_command(update, context): ...    # /done N[,M,...]
async def handle_defer_all_callback(update, context):  # callback "defer_all"
```

Каждый защищён `is_owner()`. Логика отделена в чистые функции `handle_*_logic(db, today, ...)` — как уже сделано для текста и кнопок (паттерн `Reply` / `CallbackResult`).

Регистрация в `build_application`:

```python
app.add_handler(CommandHandler("today", handle_today_command))
app.add_handler(CommandHandler("overdue", handle_overdue_command))
app.add_handler(CommandHandler("done", handle_done_command))
# CallbackQueryHandler уже есть, расширяем handle_callback_logic новой веткой "defer_all"
```

---

## Расширение `main.py`

В `main()` добавляется создание и запуск `SummaryScheduler`:

```python
scheduler = SummaryScheduler(db, app.bot,
                             owner_id=settings.telegram_owner_user_id,
                             tz=settings.timezone)
scheduler_task = asyncio.create_task(scheduler.run())
```

В `finally` — `scheduler.stop()` и `await scheduler_task`.

После `app.start()` — `await app.bot.set_my_commands(BOT_COMMANDS)` где `BOT_COMMANDS = [BotCommand("today", "..."), ...]`.

---

## Тесты

Цель — ~30 новых тестов, существующие 62 не трогаем.

**`test_summaries.py`:**
- Каждый builder на фикстурах: пустой список, одна задача в каждой секции, много задач, граничные дедлайны (вчера/сегодня/завтра/воскресенье)
- Корректность нумерации (растёт через секции)
- Корректность `numbers_to_ids` маппинга
- Форматирование строк (заголовки, эмодзи)

**`test_scheduler.py`:**
- `_compute_next_event` на фиксированном `now`: до 9:00 → ждём 9:00; между 9:00 и 21:00 → ждём 21:00; после 21:00 в будний → ждём завтра 9:00; после 18:00 в воскресенье → завтра 9:00; до 18:00 в воскресенье → ждём 18:00
- `_catchup_if_missed`: фейкаем `last_summary[morning].created_at` вчерашним → ожидаем что догоним. Шлём сегодняшним → не шлём.
- `_send_summary` — мокаем `bot.send_message`, проверяем что вызвался с правильным текстом и сохранился `last_summary`.

**`test_bot_commands.py`:**
- `handle_today_logic` — генерит сводку, сохраняет mapping
- `handle_overdue_logic` — пустой список (отдельное сообщение), один и много
- `handle_done_logic` — валидные номера, невалидные, миксы, без mapping, без аргументов, дублирующие номера, уже-done задачи
- `handle_defer_all_callback_logic` — есть что переносить → переносим, нет → отвечаем «уже»

**`test_db_queries.py`:**
- Каждый из новых методов на фикстурной БД с задачами разных дат и статусов

---

## План задач (для имплементации)

> Чек-боксы для пошагового выполнения. После каждой задачи — коммит.

### A. Расширить БД

- [ ] **A.1** Добавить `fetch_due_today`, `fetch_due_this_week`, `fetch_no_deadline`, `fetch_overdue`, `fetch_done_today` в `db.py`. Тесты в `test_db_queries.py`.
- [ ] **A.2** Добавить `defer_today_to_tomorrow` в `db.py`. Возвращает число изменённых строк. Помечает БД dirty. Тесты.

### B. Builder-функции сводок

- [ ] **B.1** Создать `summaries.py` с моделью `Summary`. Перенести `PROJECT_LABELS` (или импортировать из общего места).
- [ ] **B.2** `build_morning(tasks, today)`. Тесты.
- [ ] **B.3** `build_evening(active, done_today, today)`. Тесты.
- [ ] **B.4** `build_weekly(tasks, today)`. Тесты (read-only вид).
- [ ] **B.5** `build_overdue(tasks, today)`. Тесты, включая пустой случай.

### C. Scheduler

- [ ] **C.1** Создать `scheduler.py` с классом `SummaryScheduler` и `_compute_next_event` (чистая функция). Тесты `_compute_next_event` без БД и без сети.
- [ ] **C.2** Реализовать `_catchup_if_missed`. Тесты с моком `last_summary`.
- [ ] **C.3** Реализовать `_send_summary` для всех 4 типов (morning, evening, weekly, manual через /today, overdue через /overdue). Тесты с моком `bot.send_message`.
- [ ] **C.4** Реализовать главный цикл `run()` с `asyncio.sleep`. Тест на «один тик» через короткий интервал.

### D. Команды и callback

- [ ] **D.1** В `bot.py` добавить `handle_today_command` + чистая функция `handle_today_logic`. Тест.
- [ ] **D.2** `handle_overdue_command` + `handle_overdue_logic`. Тест (включая пустой случай).
- [ ] **D.3** `handle_done_command` + `handle_done_logic`. Парсинг аргументов (`N`, `N,M,K`), все edge cases. Тесты.
- [ ] **D.4** Расширить `handle_callback_logic` веткой `defer_all` + хендлер. Тесты.
- [ ] **D.5** В `build_application` зарегистрировать три новых `CommandHandler`-а.

### E. Точка входа

- [ ] **E.1** В `main.py` создать и запустить `SummaryScheduler`, остановить корректно в `finally`.
- [ ] **E.2** После `app.start()` вызвать `set_my_commands` со списком (`/today`, `/overdue`, `/done`).
- [ ] **E.3** Прогнать всё (62+30 тестов = ~92), убедиться что зелёное.

### F. Деплой на Pi

- [ ] **F.1** На Pi: `cd ~/tasks_bot && git pull` (через proxychains).
- [ ] **F.2** `sudo systemctl restart tasks-bot`.
- [ ] **F.3** Проверить `sudo journalctl -u tasks-bot -f`: scheduler запустился, расписание посчиталось.
- [ ] **F.4** Дождаться ближайшей сводки (или подождать `/today` вручную) и проверить в Telegram.

---

## Out of scope (Plan 3.2)

- Команды `/this N`, `/skip N`, `/del N` для воскресного обзора
- Вторичные команды типа `/list`, `/undo`
- Уведомления о приближающихся дедлайнах
- Голосовой ввод (отдельный план фазы 2)
