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

# Tasks Bot — Plan 3.1 Implementation

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

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

**Architecture:** Два новых модуля — `summaries.py` (чистые builder-функции) и `scheduler.py` (asyncio-воркер с догоном пропущенных сводок). Существующие `db.py` и `bot.py` расширяются. Никаких новых зависимостей.

**Tech Stack:** Python 3.11+, существующий `tasks_bot` package, pytest, aiosqlite, python-telegram-bot.

---

## Контекст

Дизайн-спек — `{vibe-coding} {plan} tasks-bot план-3.1 расписание – 2026-05-08.md`. Перед началом работы прочитать его — там описаны поведение команд, edge cases, расписание сводок.

Готовая инфраструктура к этому моменту:
- `Database` класс в `db.py` с методами `save_summary`, `load_summary`, `load_latest_summary` (Plan 1 уже сделал)
- `Task`, `TaskStatus`, `DeadlineKind` в `models.py`
- `now_tz`, `today_tz` в `clock.py`
- `nearest_sunday_inclusive` в `dates.py`
- `PROJECT_LABELS` в `bot.py:12-15` — переезжает в `summaries.py`
- `is_owner`, `handle_text_logic`, `handle_callback_logic`, `build_application` в `bot.py`
- `SyncWorker`, `BackupWorker` в `git_sync.py` (паттерн async-воркеров с `stop()`)
- `tests/conftest.py` — фикстура `db` (in-memory SQLite)

---

## Структура файлов

```
src/tasks_bot/
├── summaries.py              ← Создать
├── scheduler.py              ← Создать
├── bot.py                    ← Расширить (handlers, callback)
├── db.py                     ← Расширить (query-методы)
└── main.py                   ← Расширить (wire scheduler)

tests/
├── test_summaries.py         ← Создать
├── test_scheduler.py         ← Создать
├── test_db_queries.py        ← Создать
└── test_bot_commands.py      ← Создать
```

---

## Запуск тестов и git workflow

Все тесты прогонять из корня `projects/vibe-coding/tasks-bot/`:

```bash
cd projects/vibe-coding/tasks-bot
source .venv/bin/activate
pytest -v
```

После каждой задачи — коммит. Сообщения коммитов:
- `feat: ...` — новая фича
- `test: ...` — только тесты
- `refactor: ...` — рефакторинг

Перед стартом — убедиться что 62 существующих теста зелёные:

```bash
pytest -v
# Expected: 62 passed
```

---

# Task 1: DB-методы для выборок по дедлайну (часть 1)

**Files:**
- Modify: `src/tasks_bot/db.py`
- Test: `tests/test_db_queries.py` (создать)

- [ ] **Step 1: Написать падающие тесты для `fetch_due_today` и `fetch_due_this_week`**

Создать `tests/test_db_queries.py`:

```python
import pytest
from datetime import date, datetime, timedelta
from tasks_bot.models import TaskStatus, DeadlineKind


async def _insert(db, text, project, deadline, kind, status, now=None):
    now = now or datetime(2026, 5, 8, 10, 0, 0)
    tid = await db.insert_task(
        text=text, project=project, deadline=deadline,
        deadline_kind=kind, status=status, now=now,
    )
    if status != TaskStatus.PENDING_CONFIRMATION:
        await db.update_status(tid, status, now=now)
    return tid


async def test_fetch_due_today_returns_only_today(db):
    today = date(2026, 5, 8)
    await _insert(db, "сегодня", "mars", today, DeadlineKind.DATE, TaskStatus.CONFIRMED)
    await _insert(db, "вчера", "mars", today - timedelta(days=1),
                  DeadlineKind.DATE, TaskStatus.CONFIRMED)
    await _insert(db, "завтра", "mars", today + timedelta(days=1),
                  DeadlineKind.DATE, TaskStatus.CONFIRMED)
    await _insert(db, "без срока", "mars", None, DeadlineKind.NONE,
                  TaskStatus.CONFIRMED)

    rows = await db.fetch_due_today(today)
    texts = [t.text for t in rows]
    assert texts == ["сегодня"]


async def test_fetch_due_today_excludes_done_and_pending(db):
    today = date(2026, 5, 8)
    await _insert(db, "done", "mars", today, DeadlineKind.DATE, TaskStatus.DONE)
    await _insert(db, "pending", "mars", today, DeadlineKind.DATE,
                  TaskStatus.PENDING_CONFIRMATION)
    await _insert(db, "active", "mars", today, DeadlineKind.DATE,
                  TaskStatus.CONFIRMED)
    rows = await db.fetch_due_today(today)
    assert [t.text for t in rows] == ["active"]


async def test_fetch_due_this_week_excludes_today_and_overdue(db):
    today = date(2026, 5, 8)  # Friday
    sunday = date(2026, 5, 10)
    await _insert(db, "today", "mars", today, DeadlineKind.DATE, TaskStatus.CONFIRMED)
    await _insert(db, "saturday", "mars", today + timedelta(days=1),
                  DeadlineKind.DATE, TaskStatus.CONFIRMED)
    await _insert(db, "sunday", "mars", sunday, DeadlineKind.WEEK,
                  TaskStatus.CONFIRMED)
    await _insert(db, "next-week", "mars", sunday + timedelta(days=1),
                  DeadlineKind.DATE, TaskStatus.CONFIRMED)
    await _insert(db, "overdue", "mars", today - timedelta(days=1),
                  DeadlineKind.DATE, TaskStatus.CONFIRMED)

    rows = await db.fetch_due_this_week(today)
    texts = sorted(t.text for t in rows)
    assert texts == ["saturday", "sunday"]
```

- [ ] **Step 2: Прогнать тесты — должны упасть**

```bash
pytest tests/test_db_queries.py -v
```
Expected: FAIL — `AttributeError: 'Database' object has no attribute 'fetch_due_today'`

- [ ] **Step 3: Реализовать методы в `db.py`**

Добавить в конец класса `Database` (перед последней закрывающей скобкой):

```python
async def fetch_due_today(self, today) -> list:
    rows = await self.fetchall(
        "SELECT * FROM tasks WHERE status = ? AND deadline = ? ORDER BY id",
        (TaskStatus.CONFIRMED.value, today.isoformat()),
    )
    return [_row_to_task(r) for r in rows]

async def fetch_due_this_week(self, today) -> list:
    from .dates import nearest_sunday_inclusive
    sunday = nearest_sunday_inclusive(today)
    rows = await self.fetchall(
        "SELECT * FROM tasks WHERE status = ? "
        "AND deadline > ? AND deadline <= ? ORDER BY id",
        (TaskStatus.CONFIRMED.value, today.isoformat(), sunday.isoformat()),
    )
    return [_row_to_task(r) for r in rows]
```

- [ ] **Step 4: Прогнать тесты — должны пройти**

```bash
pytest tests/test_db_queries.py -v
```
Expected: PASS — 3 tests passed.

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/db.py tests/test_db_queries.py
git commit -m "feat: add fetch_due_today and fetch_due_this_week DB queries"
```

---

# Task 2: DB-методы (часть 2): no_deadline, overdue

**Files:**
- Modify: `src/tasks_bot/db.py`
- Test: `tests/test_db_queries.py`

- [ ] **Step 1: Добавить тесты в `test_db_queries.py`**

```python
async def test_fetch_no_deadline(db):
    today = date(2026, 5, 8)
    await _insert(db, "no-1", "mars", None, DeadlineKind.NONE, TaskStatus.CONFIRMED)
    await _insert(db, "no-2", "channel", None, DeadlineKind.NONE,
                  TaskStatus.CONFIRMED)
    await _insert(db, "with", "mars", today, DeadlineKind.DATE,
                  TaskStatus.CONFIRMED)
    rows = await db.fetch_no_deadline()
    texts = sorted(t.text for t in rows)
    assert texts == ["no-1", "no-2"]


async def test_fetch_overdue(db):
    today = date(2026, 5, 8)
    await _insert(db, "overdue", "mars", today - timedelta(days=2),
                  DeadlineKind.DATE, TaskStatus.CONFIRMED)
    await _insert(db, "today", "mars", today, DeadlineKind.DATE,
                  TaskStatus.CONFIRMED)
    await _insert(db, "future", "mars", today + timedelta(days=1),
                  DeadlineKind.DATE, TaskStatus.CONFIRMED)
    await _insert(db, "overdue-done", "mars", today - timedelta(days=2),
                  DeadlineKind.DATE, TaskStatus.DONE)
    rows = await db.fetch_overdue(today)
    assert [t.text for t in rows] == ["overdue"]
```

- [ ] **Step 2: Прогнать — должны упасть**

```bash
pytest tests/test_db_queries.py::test_fetch_no_deadline tests/test_db_queries.py::test_fetch_overdue -v
```

- [ ] **Step 3: Реализовать методы в `db.py`**

```python
async def fetch_no_deadline(self) -> list:
    rows = await self.fetchall(
        "SELECT * FROM tasks WHERE status = ? AND deadline IS NULL ORDER BY id",
        (TaskStatus.CONFIRMED.value,),
    )
    return [_row_to_task(r) for r in rows]

async def fetch_overdue(self, today) -> list:
    rows = await self.fetchall(
        "SELECT * FROM tasks WHERE status = ? AND deadline < ? ORDER BY id",
        (TaskStatus.CONFIRMED.value, today.isoformat()),
    )
    return [_row_to_task(r) for r in rows]
```

- [ ] **Step 4: Прогнать — должны пройти**

```bash
pytest tests/test_db_queries.py -v
```
Expected: 5 passed.

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/db.py tests/test_db_queries.py
git commit -m "feat: add fetch_no_deadline and fetch_overdue DB queries"
```

---

# Task 3: DB-метод `fetch_done_today`

**Files:**
- Modify: `src/tasks_bot/db.py`
- Test: `tests/test_db_queries.py`

- [ ] **Step 1: Тест**

Добавить в `test_db_queries.py`:

```python
async def test_fetch_done_today(db):
    today = date(2026, 5, 8)
    yesterday_dt = datetime(2026, 5, 7, 18, 0, 0)
    today_dt = datetime(2026, 5, 8, 14, 0, 0)
    tomorrow_dt = datetime(2026, 5, 9, 9, 0, 0)

    # done вчера
    tid1 = await _insert(db, "yesterday", "mars", today, DeadlineKind.DATE,
                         TaskStatus.CONFIRMED)
    await db.update_status(tid1, TaskStatus.DONE, now=yesterday_dt)
    # done сегодня
    tid2 = await _insert(db, "today1", "mars", today, DeadlineKind.DATE,
                         TaskStatus.CONFIRMED)
    await db.update_status(tid2, TaskStatus.DONE, now=today_dt)
    # ещё done сегодня
    tid3 = await _insert(db, "today2", "channel", None, DeadlineKind.NONE,
                         TaskStatus.CONFIRMED)
    await db.update_status(tid3, TaskStatus.DONE, now=today_dt)
    # активная
    await _insert(db, "active", "mars", today, DeadlineKind.DATE,
                  TaskStatus.CONFIRMED)

    rows = await db.fetch_done_today(today)
    texts = sorted(t.text for t in rows)
    assert texts == ["today1", "today2"]
```

- [ ] **Step 2: Прогнать — упадёт**

```bash
pytest tests/test_db_queries.py::test_fetch_done_today -v
```

- [ ] **Step 3: Реализация**

В `db.py`:

```python
async def fetch_done_today(self, today) -> list:
    rows = await self.fetchall(
        "SELECT * FROM tasks WHERE status = ? "
        "AND DATE(done_at) = ? ORDER BY id",
        (TaskStatus.DONE.value, today.isoformat()),
    )
    return [_row_to_task(r) for r in rows]
```

- [ ] **Step 4: Прогнать**

```bash
pytest tests/test_db_queries.py -v
```
Expected: 6 passed.

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/db.py tests/test_db_queries.py
git commit -m "feat: add fetch_done_today DB query"
```

---

# Task 4: DB-метод `defer_today_to_tomorrow`

**Files:**
- Modify: `src/tasks_bot/db.py`
- Test: `tests/test_db_queries.py`

- [ ] **Step 1: Тест**

```python
async def test_defer_today_to_tomorrow(db):
    today = date(2026, 5, 8)
    now = datetime(2026, 5, 8, 21, 0, 0)
    await _insert(db, "t1", "mars", today, DeadlineKind.DATE, TaskStatus.CONFIRMED)
    await _insert(db, "t2", "channel", today, DeadlineKind.WEEK, TaskStatus.CONFIRMED)
    await _insert(db, "future", "mars", today + timedelta(days=2),
                  DeadlineKind.DATE, TaskStatus.CONFIRMED)
    await _insert(db, "no", "mars", None, DeadlineKind.NONE, TaskStatus.CONFIRMED)

    count = await db.defer_today_to_tomorrow(today, now=now)
    assert count == 2

    rows = await db.fetchall("SELECT text, deadline, deadline_kind FROM tasks "
                              "WHERE deadline = ? ORDER BY id",
                              (date(2026, 5, 9).isoformat(),))
    deferred = sorted((r["text"], r["deadline_kind"]) for r in rows)
    assert deferred == [("t1", "date"), ("t2", "date")]


async def test_defer_today_marks_dirty(db):
    today = date(2026, 5, 8)
    now = datetime(2026, 5, 8, 21, 0, 0)
    await _insert(db, "x", "mars", today, DeadlineKind.DATE, TaskStatus.CONFIRMED)
    before = db.dirty_generation
    await db.defer_today_to_tomorrow(today, now=now)
    assert db.dirty_generation > before


async def test_defer_today_returns_zero_when_nothing(db):
    today = date(2026, 5, 8)
    now = datetime(2026, 5, 8, 21, 0, 0)
    count = await db.defer_today_to_tomorrow(today, now=now)
    assert count == 0
```

- [ ] **Step 2: Прогнать — упадут**

```bash
pytest tests/test_db_queries.py -v -k defer
```

- [ ] **Step 3: Реализация**

В `db.py`:

```python
async def defer_today_to_tomorrow(self, today, now) -> int:
    tomorrow = (today + timedelta(days=1)).isoformat()
    cur = await self.conn.execute(
        "UPDATE tasks SET deadline = ?, deadline_kind = 'date', updated_at = ? "
        "WHERE status = ? AND deadline = ?",
        (tomorrow, now.isoformat(), TaskStatus.CONFIRMED.value, today.isoformat()),
    )
    await self.conn.commit()
    if cur.rowcount > 0:
        self._mark_dirty()
    return cur.rowcount
```

В шапку файла добавить `from datetime import timedelta` если ещё нет.

- [ ] **Step 4: Прогнать**

```bash
pytest tests/test_db_queries.py -v
```
Expected: 9 passed total.

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/db.py tests/test_db_queries.py
git commit -m "feat: add defer_today_to_tomorrow DB query"
```

---

# Task 5: `summaries.py` — модель `Summary` и `build_morning`

**Files:**
- Create: `src/tasks_bot/summaries.py`
- Test: `tests/test_summaries.py` (создать)

- [ ] **Step 1: Тесты**

Создать `tests/test_summaries.py`:

```python
import pytest
from datetime import date, datetime
from tasks_bot.models import Task, TaskStatus, DeadlineKind
from tasks_bot.summaries import Summary, build_morning, PROJECT_LABELS


def _task(tid, text, project, deadline=None, kind=DeadlineKind.NONE,
          status=TaskStatus.CONFIRMED):
    now = datetime(2026, 5, 8, 10, 0, 0)
    return Task(
        id=tid, text=text, project=project, deadline=deadline,
        deadline_kind=kind, status=status,
        created_at=now, updated_at=now,
    )


def test_project_labels_present():
    assert PROJECT_LABELS["mars"] == "Марс"
    assert PROJECT_LABELS["_inbox"] == "Входящие"


def test_morning_empty():
    s = build_morning(today=[], week=[], no_deadline=[], today_date=date(2026, 5, 8))
    assert "пусто" in s.text.lower() or "ничего" in s.text.lower()
    assert s.numbers_to_ids == {}


def test_morning_three_sections_numbering():
    today = date(2026, 5, 8)
    today_tasks = [_task(10, "позвонить Ане", "mars", today, DeadlineKind.DATE)]
    week_tasks = [_task(20, "идея для канала", "channel", date(2026, 5, 10),
                          DeadlineKind.WEEK)]
    no_dl = [_task(30, "книга", "life")]
    s = build_morning(today=today_tasks, week=week_tasks, no_deadline=no_dl,
                       today_date=today)
    assert "1. позвонить Ане" in s.text
    assert "2. идея для канала" in s.text
    assert "3. книга" in s.text
    assert s.numbers_to_ids == {1: 10, 2: 20, 3: 30}
    assert "Марс" in s.text
    assert "Канал" in s.text
    assert "Жизнь" in s.text


def test_morning_section_headers():
    today = date(2026, 5, 8)
    s = build_morning(
        today=[_task(1, "a", "mars", today, DeadlineKind.DATE)],
        week=[_task(2, "b", "mars", date(2026, 5, 10), DeadlineKind.WEEK)],
        no_deadline=[_task(3, "c", "mars")],
        today_date=today,
    )
    assert "На сегодня" in s.text
    assert "На этой неделе" in s.text
    assert "Без срока" in s.text


def test_morning_skips_empty_sections():
    today = date(2026, 5, 8)
    s = build_morning(
        today=[_task(1, "a", "mars", today, DeadlineKind.DATE)],
        week=[], no_deadline=[], today_date=today,
    )
    assert "На сегодня" in s.text
    assert "На этой неделе" not in s.text
    assert "Без срока" not in s.text
```

- [ ] **Step 2: Прогнать — упадут (нет модуля)**

```bash
pytest tests/test_summaries.py -v
```
Expected: ImportError.

- [ ] **Step 3: Создать `src/tasks_bot/summaries.py`**

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


PROJECT_LABELS = {
    "mars": "Марс",
    "channel": "Канал",
    "consulting": "Консалтинг",
    "life": "Жизнь",
    "_inbox": "Входящие",
}


@dataclass
class Summary:
    text: str
    numbers_to_ids: dict[int, int] = field(default_factory=dict)


def _format_task_line(n: int, t: Task) -> str:
    label = PROJECT_LABELS.get(t.project, t.project)
    return f"{n}. {t.text} — {label}"


def build_morning(today: list[Task], week: list[Task],
                   no_deadline: list[Task], today_date: date) -> Summary:
    if not today and not week and not no_deadline:
        return Summary(text="Доброе утро ☀️\n\nНа сегодня ничего не назначено.")

    lines = ["Доброе утро ☀️", ""]
    mapping: dict[int, int] = {}
    n = 0

    if today:
        lines.append("🔥 На сегодня")
        for t in today:
            n += 1
            mapping[n] = t.id
            lines.append(_format_task_line(n, t))
        lines.append("")

    if week:
        lines.append("📅 На этой неделе")
        for t in week:
            n += 1
            mapping[n] = t.id
            lines.append(_format_task_line(n, t))
        lines.append("")

    if no_deadline:
        lines.append("💭 Без срока")
        for t in no_deadline:
            n += 1
            mapping[n] = t.id
            lines.append(_format_task_line(n, t))
        lines.append("")

    lines.append("Закрыть: /done 1   или   /done 1,3,5")
    return Summary(text="\n".join(lines).strip(), numbers_to_ids=mapping)
```

- [ ] **Step 4: Прогнать — должны пройти**

```bash
pytest tests/test_summaries.py -v
```
Expected: 5 passed.

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/summaries.py tests/test_summaries.py
git commit -m "feat: add Summary model and build_morning"
```

---

# Task 6: `summaries.py` — `build_evening`

**Files:**
- Modify: `src/tasks_bot/summaries.py`
- Test: `tests/test_summaries.py`

- [ ] **Step 1: Тесты**

Добавить в `test_summaries.py`:

```python
from tasks_bot.summaries import build_evening


def test_evening_with_done_and_remaining():
    today = date(2026, 5, 8)
    done = [
        _task(10, "позвонить Ане", "mars", today, DeadlineKind.DATE,
              status=TaskStatus.DONE),
        _task(11, "купить корм коту", "life", today, DeadlineKind.DATE,
              status=TaskStatus.DONE),
    ]
    remaining = [_task(12, "подготовиться", "consulting", today, DeadlineKind.DATE)]
    s = build_evening(active_today=remaining, done_today=done, today_date=today)
    assert "Закрыла сегодня" in s.text
    assert "позвонить Ане" in s.text
    assert "купить корм коту" in s.text
    assert "Осталось на сегодня" in s.text
    assert "1. подготовиться" in s.text
    assert s.numbers_to_ids == {1: 12}


def test_evening_only_done():
    today = date(2026, 5, 8)
    done = [_task(10, "сделано", "mars", today, DeadlineKind.DATE,
                   status=TaskStatus.DONE)]
    s = build_evening(active_today=[], done_today=done, today_date=today)
    assert "Закрыла сегодня" in s.text
    assert "Осталось" not in s.text
    assert s.numbers_to_ids == {}


def test_evening_empty_day():
    today = date(2026, 5, 8)
    s = build_evening(active_today=[], done_today=[], today_date=today)
    assert "тихий день" in s.text.lower() or "пусто" in s.text.lower()
    assert s.numbers_to_ids == {}
```

- [ ] **Step 2: Прогнать — упадут**

```bash
pytest tests/test_summaries.py -v -k evening
```

- [ ] **Step 3: Реализация в `summaries.py`**

Дописать в конец файла:

```python
def build_evening(active_today: list[Task], done_today: list[Task],
                   today_date: date) -> Summary:
    if not active_today and not done_today:
        return Summary(text="🌙 Тихий день — задач не было.")

    lines = ["🌙 Итог дня", ""]
    mapping: dict[int, int] = {}

    if done_today:
        lines.append(f"Закрыла сегодня ({len(done_today)}):")
        for t in done_today:
            label = PROJECT_LABELS.get(t.project, t.project)
            lines.append(f"— {t.text} — {label}")
        lines.append("")

    if active_today:
        lines.append(f"Осталось на сегодня ({len(active_today)}):")
        n = 0
        for t in active_today:
            n += 1
            mapping[n] = t.id
            lines.append(_format_task_line(n, t))

    return Summary(text="\n".join(lines).strip(), numbers_to_ids=mapping)
```

- [ ] **Step 4: Прогнать**

```bash
pytest tests/test_summaries.py -v
```
Expected: 8 passed.

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/summaries.py tests/test_summaries.py
git commit -m "feat: add build_evening summary"
```

---

# Task 7: `summaries.py` — `build_weekly` и `build_overdue`

**Files:**
- Modify: `src/tasks_bot/summaries.py`
- Test: `tests/test_summaries.py`

- [ ] **Step 1: Тесты**

```python
from tasks_bot.summaries import build_weekly, build_overdue


def test_weekly_overdue_and_no_deadline():
    today = date(2026, 5, 10)  # Sunday
    overdue = [_task(10, "просрочено", "mars", date(2026, 5, 7),
                     DeadlineKind.DATE)]
    no_dl = [_task(20, "идея", "channel"),
             _task(21, "купить наушники", "life")]
    s = build_weekly(overdue=overdue, no_deadline=no_dl, today_date=today)
    assert "Воскресный обзор" in s.text
    assert "Просроченные" in s.text
    assert "1. просрочено" in s.text
    assert "2. идея" in s.text
    assert "3. купить наушники" in s.text
    assert s.numbers_to_ids == {1: 10, 2: 20, 3: 21}


def test_weekly_empty():
    today = date(2026, 5, 10)
    s = build_weekly(overdue=[], no_deadline=[], today_date=today)
    assert "беклог пуст" in s.text.lower() or "пусто" in s.text.lower()
    assert s.numbers_to_ids == {}


def test_overdue_with_tasks():
    today = date(2026, 5, 8)
    tasks = [
        _task(10, "вчерашняя", "mars", date(2026, 5, 7), DeadlineKind.DATE),
        _task(11, "трёхдневная", "channel", date(2026, 5, 5), DeadlineKind.DATE),
    ]
    s = build_overdue(overdue=tasks, today_date=today)
    assert "Просроченные" in s.text
    assert "1. вчерашняя" in s.text
    assert "2. трёхдневная" in s.text
    assert s.numbers_to_ids == {1: 10, 2: 11}


def test_overdue_empty():
    today = date(2026, 5, 8)
    s = build_overdue(overdue=[], today_date=today)
    assert "Просроченных нет" in s.text
    assert s.numbers_to_ids == {}
```

- [ ] **Step 2: Прогнать — упадут**

```bash
pytest tests/test_summaries.py -v -k "weekly or overdue"
```

- [ ] **Step 3: Реализация**

Дописать в `summaries.py`:

```python
def build_weekly(overdue: list[Task], no_deadline: list[Task],
                  today_date: date) -> Summary:
    if not overdue and not no_deadline:
        return Summary(text="📋 Воскресный обзор\n\nБеклог пуст ✨")

    lines = ["📋 Воскресный обзор", ""]
    mapping: dict[int, int] = {}
    n = 0

    if overdue:
        lines.append(f"🔴 Просроченные ({len(overdue)}):")
        for t in overdue:
            n += 1
            mapping[n] = t.id
            lines.append(_format_task_line(n, t))
        lines.append("")

    if no_deadline:
        lines.append(f"💭 Без срока ({len(no_deadline)}):")
        for t in no_deadline:
            n += 1
            mapping[n] = t.id
            lines.append(_format_task_line(n, t))

    return Summary(text="\n".join(lines).strip(), numbers_to_ids=mapping)


def build_overdue(overdue: list[Task], today_date: date) -> Summary:
    if not overdue:
        return Summary(text="Просроченных нет ✨")

    lines = [f"🔴 Просроченные ({len(overdue)}):"]
    mapping: dict[int, int] = {}
    n = 0
    for t in overdue:
        n += 1
        mapping[n] = t.id
        lines.append(_format_task_line(n, t))
    lines.append("")
    lines.append("Закрыть: /done 1   или   /done 1,2")
    return Summary(text="\n".join(lines).strip(), numbers_to_ids=mapping)
```

- [ ] **Step 4: Прогнать**

```bash
pytest tests/test_summaries.py -v
```
Expected: 12 passed.

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/summaries.py tests/test_summaries.py
git commit -m "feat: add build_weekly and build_overdue summaries"
```

---

# Task 8: `scheduler.py` — `_compute_next_event` (чистая функция)

**Files:**
- Create: `src/tasks_bot/scheduler.py`
- Test: `tests/test_scheduler.py` (создать)

- [ ] **Step 1: Тесты**

```python
import pytest
from datetime import datetime
from zoneinfo import ZoneInfo
from tasks_bot.scheduler import compute_next_event, EventType


TZ = "Asia/Yekaterinburg"


def _dt(y, m, d, h, minute=0):
    return datetime(y, m, d, h, minute, tzinfo=ZoneInfo(TZ))


def test_before_morning_returns_morning():
    now = _dt(2026, 5, 8, 7, 30)  # Friday 7:30
    e = compute_next_event(now)
    assert e.type == EventType.MORNING
    assert e.when == _dt(2026, 5, 8, 9)


def test_between_morning_and_evening_returns_evening():
    now = _dt(2026, 5, 8, 14, 0)  # Friday 14:00
    e = compute_next_event(now)
    assert e.type == EventType.EVENING
    assert e.when == _dt(2026, 5, 8, 21)


def test_after_evening_weekday_returns_tomorrow_morning():
    now = _dt(2026, 5, 8, 22, 0)  # Friday 22:00
    e = compute_next_event(now)
    assert e.type == EventType.MORNING
    assert e.when == _dt(2026, 5, 9, 9)


def test_sunday_before_18_returns_weekly():
    now = _dt(2026, 5, 10, 14, 0)  # Sunday 14:00
    e = compute_next_event(now)
    assert e.type == EventType.WEEKLY
    assert e.when == _dt(2026, 5, 10, 18)


def test_sunday_morning_returns_morning_first():
    now = _dt(2026, 5, 10, 7, 0)  # Sunday 7:00
    e = compute_next_event(now)
    assert e.type == EventType.MORNING
    assert e.when == _dt(2026, 5, 10, 9)


def test_sunday_after_18_returns_evening():
    now = _dt(2026, 5, 10, 19, 0)  # Sunday 19:00
    e = compute_next_event(now)
    assert e.type == EventType.EVENING
    assert e.when == _dt(2026, 5, 10, 21)


def test_sunday_after_evening_returns_monday_morning():
    now = _dt(2026, 5, 10, 22, 0)  # Sunday 22:00
    e = compute_next_event(now)
    assert e.type == EventType.MORNING
    assert e.when == _dt(2026, 5, 11, 9)
```

- [ ] **Step 2: Прогнать — ImportError**

```bash
pytest tests/test_scheduler.py -v
```

- [ ] **Step 3: Реализация в `src/tasks_bot/scheduler.py`**

```python
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import StrEnum
from zoneinfo import ZoneInfo


class EventType(StrEnum):
    MORNING = "morning"
    EVENING = "evening"
    WEEKLY = "weekly"


@dataclass
class Event:
    type: EventType
    when: datetime


MORNING_HOUR = 9
EVENING_HOUR = 21
WEEKLY_HOUR = 18  # Sunday


def compute_next_event(now: datetime) -> Event:
    """Return the closest scheduled event strictly after `now`.

    Pure function — no I/O, no side effects.
    `now` must be timezone-aware.
    """
    candidates: list[Event] = []
    today = now.date()
    tz = now.tzinfo

    # Today's morning
    candidates.append(Event(EventType.MORNING,
                             datetime.combine(today, datetime.min.time(),
                                              tzinfo=tz).replace(hour=MORNING_HOUR)))
    # Today's evening
    candidates.append(Event(EventType.EVENING,
                             datetime.combine(today, datetime.min.time(),
                                              tzinfo=tz).replace(hour=EVENING_HOUR)))
    # Today's weekly (only if Sunday)
    if today.weekday() == 6:
        candidates.append(Event(EventType.WEEKLY,
                                 datetime.combine(today, datetime.min.time(),
                                                  tzinfo=tz).replace(hour=WEEKLY_HOUR)))

    future = [c for c in candidates if c.when > now]
    if future:
        return min(future, key=lambda c: c.when)

    # All today's events have passed — return tomorrow's morning
    tomorrow = today + timedelta(days=1)
    return Event(EventType.MORNING,
                  datetime.combine(tomorrow, datetime.min.time(),
                                    tzinfo=tz).replace(hour=MORNING_HOUR))
```

- [ ] **Step 4: Прогнать**

```bash
pytest tests/test_scheduler.py -v
```
Expected: 7 passed.

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/scheduler.py tests/test_scheduler.py
git commit -m "feat: add compute_next_event for summary scheduler"
```

---

# Task 9: `scheduler.py` — `is_period_missed` (логика догона)

**Files:**
- Modify: `src/tasks_bot/scheduler.py`
- Test: `tests/test_scheduler.py`

- [ ] **Step 1: Тесты**

Добавить в `test_scheduler.py`:

```python
from tasks_bot.scheduler import is_period_missed


def test_morning_missed_when_past_9_and_no_today_summary():
    now = _dt(2026, 5, 8, 10, 0)  # Friday 10:00
    last_at = _dt(2026, 5, 7, 9, 0)  # вчерашняя
    assert is_period_missed(EventType.MORNING, now, last_at) is True


def test_morning_not_missed_when_summary_today():
    now = _dt(2026, 5, 8, 10, 0)
    last_at = _dt(2026, 5, 8, 9, 0)
    assert is_period_missed(EventType.MORNING, now, last_at) is False


def test_morning_not_missed_when_before_9():
    now = _dt(2026, 5, 8, 8, 0)
    last_at = _dt(2026, 5, 7, 9, 0)
    assert is_period_missed(EventType.MORNING, now, last_at) is False


def test_morning_missed_when_no_history():
    now = _dt(2026, 5, 8, 10, 0)
    assert is_period_missed(EventType.MORNING, now, None) is True


def test_evening_missed_after_21():
    now = _dt(2026, 5, 8, 22, 0)
    assert is_period_missed(EventType.EVENING, now, None) is True


def test_weekly_missed_only_on_sunday_after_18():
    sunday_19 = _dt(2026, 5, 10, 19, 0)
    monday_9 = _dt(2026, 5, 11, 9, 0)
    assert is_period_missed(EventType.WEEKLY, sunday_19, None) is True
    assert is_period_missed(EventType.WEEKLY, monday_9, None) is False


def test_weekly_not_missed_when_already_sent_this_sunday():
    sunday_19 = _dt(2026, 5, 10, 19, 0)
    sunday_18_30 = _dt(2026, 5, 10, 18, 30)
    assert is_period_missed(EventType.WEEKLY, sunday_19, sunday_18_30) is False
```

- [ ] **Step 2: Прогнать — упадут**

```bash
pytest tests/test_scheduler.py -v -k missed
```

- [ ] **Step 3: Реализация**

Добавить в `scheduler.py`:

```python
def is_period_missed(event_type: EventType, now: datetime,
                      last_sent_at: datetime | None) -> bool:
    """Should we catch up this scheduled event?"""
    today = now.date()
    tz = now.tzinfo

    if event_type == EventType.MORNING:
        scheduled = datetime.combine(today, datetime.min.time(),
                                       tzinfo=tz).replace(hour=MORNING_HOUR)
        if now < scheduled:
            return False
        if last_sent_at and last_sent_at.date() == today:
            return False
        return True

    if event_type == EventType.EVENING:
        scheduled = datetime.combine(today, datetime.min.time(),
                                       tzinfo=tz).replace(hour=EVENING_HOUR)
        if now < scheduled:
            return False
        if last_sent_at and last_sent_at.date() == today:
            return False
        return True

    if event_type == EventType.WEEKLY:
        # Only Sunday after 18:00
        if today.weekday() != 6:
            return False
        scheduled = datetime.combine(today, datetime.min.time(),
                                       tzinfo=tz).replace(hour=WEEKLY_HOUR)
        if now < scheduled:
            return False
        if last_sent_at and last_sent_at.date() == today:
            return False
        return True

    return False
```

- [ ] **Step 4: Прогнать**

```bash
pytest tests/test_scheduler.py -v
```
Expected: 14 passed.

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/scheduler.py tests/test_scheduler.py
git commit -m "feat: add is_period_missed catchup logic"
```

---

# Task 10: `SummaryScheduler` класс с `_send_summary`

**Files:**
- Modify: `src/tasks_bot/scheduler.py`
- Test: `tests/test_scheduler.py`

- [ ] **Step 1: Тест отправки утренней сводки**

Добавить в `test_scheduler.py`:

```python
from unittest.mock import AsyncMock, MagicMock
from datetime import date, datetime
from tasks_bot.scheduler import SummaryScheduler
from tasks_bot.models import Task, TaskStatus, DeadlineKind


def _make_task(tid, deadline=None, kind=DeadlineKind.NONE):
    now = datetime(2026, 5, 8, 10, 0, 0)
    return Task(id=tid, text=f"task-{tid}", project="mars",
                 deadline=deadline, deadline_kind=kind,
                 status=TaskStatus.CONFIRMED,
                 created_at=now, updated_at=now)


async def test_send_morning_calls_bot_and_saves_summary(db, monkeypatch):
    bot = MagicMock()
    bot.send_message = AsyncMock()
    sched = SummaryScheduler(db=db, bot=bot, owner_id=42, tz=TZ)
    today = date(2026, 5, 8)
    monkeypatch.setattr("tasks_bot.scheduler.today_tz", lambda tz: today)
    monkeypatch.setattr("tasks_bot.scheduler.now_tz",
                         lambda tz: _dt(2026, 5, 8, 9, 0))

    await db.insert_task(text="x", project="mars", deadline=today,
                          deadline_kind=DeadlineKind.DATE,
                          status=TaskStatus.CONFIRMED,
                          now=datetime(2026, 5, 8, 8))

    await sched._send_summary(EventType.MORNING)

    bot.send_message.assert_awaited_once()
    args = bot.send_message.await_args
    assert args.kwargs["chat_id"] == 42
    assert "На сегодня" in args.kwargs["text"]
    saved = await db.load_summary("morning")
    assert saved is not None
```

- [ ] **Step 2: Прогнать — упадёт**

```bash
pytest tests/test_scheduler.py::test_send_morning_calls_bot_and_saves_summary -v
```

- [ ] **Step 3: Реализация — добавить класс в `scheduler.py`**

```python
import asyncio
import logging

from .clock import now_tz, today_tz
from .db import Database
from .summaries import (
    build_morning, build_evening, build_weekly, build_overdue, Summary,
)


log = logging.getLogger(__name__)


class SummaryScheduler:
    def __init__(self, db: Database, bot, owner_id: int, tz: str):
        self.db = db
        self.bot = bot
        self.owner_id = owner_id
        self.tz = tz
        self._stopped = False

    def stop(self):
        self._stopped = True

    async def _build_morning(self) -> Summary:
        today = today_tz(self.tz)
        return build_morning(
            today=await self.db.fetch_due_today(today),
            week=await self.db.fetch_due_this_week(today),
            no_deadline=await self.db.fetch_no_deadline(),
            today_date=today,
        )

    async def _build_evening(self) -> Summary:
        today = today_tz(self.tz)
        return build_evening(
            active_today=await self.db.fetch_due_today(today),
            done_today=await self.db.fetch_done_today(today),
            today_date=today,
        )

    async def _build_weekly(self) -> Summary:
        today = today_tz(self.tz)
        return build_weekly(
            overdue=await self.db.fetch_overdue(today),
            no_deadline=await self.db.fetch_no_deadline(),
            today_date=today,
        )

    async def _send_summary(self, event_type: EventType):
        if event_type == EventType.MORNING:
            summary = await self._build_morning()
            key = "morning"
            reply_markup = None
        elif event_type == EventType.EVENING:
            summary = await self._build_evening()
            key = "evening"
            reply_markup = self._defer_keyboard()
        elif event_type == EventType.WEEKLY:
            summary = await self._build_weekly()
            key = "weekly"
            reply_markup = None
        else:
            return

        try:
            await self.bot.send_message(chat_id=self.owner_id,
                                          text=summary.text,
                                          reply_markup=reply_markup)
        except Exception as e:
            log.error("Failed to send %s summary: %s", event_type, e)
            return

        await self.db.save_summary(
            summary_type=key,
            numbers_to_ids=summary.numbers_to_ids,
            now=now_tz(self.tz),
        )

    def _defer_keyboard(self):
        from telegram import InlineKeyboardButton, InlineKeyboardMarkup
        return InlineKeyboardMarkup([[
            InlineKeyboardButton("Перенести всё", callback_data="defer_all"),
        ]])
```

- [ ] **Step 4: Прогнать**

```bash
pytest tests/test_scheduler.py -v
```
Expected: 15 passed.

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/scheduler.py tests/test_scheduler.py
git commit -m "feat: add SummaryScheduler class with _send_summary"
```

---

# Task 11: `SummaryScheduler.run()` — главный цикл и догон

**Files:**
- Modify: `src/tasks_bot/scheduler.py`
- Test: `tests/test_scheduler.py`

- [ ] **Step 1: Тесты для `_catchup_if_missed` и `run`**

```python
async def test_catchup_sends_when_morning_missed(db, monkeypatch):
    bot = MagicMock()
    bot.send_message = AsyncMock()
    sched = SummaryScheduler(db=db, bot=bot, owner_id=42, tz=TZ)
    today = date(2026, 5, 8)
    now_local = _dt(2026, 5, 8, 10, 0)
    monkeypatch.setattr("tasks_bot.scheduler.today_tz", lambda tz: today)
    monkeypatch.setattr("tasks_bot.scheduler.now_tz", lambda tz: now_local)

    # last morning was yesterday — should catch up
    await db.save_summary("morning", {}, now=_dt(2026, 5, 7, 9, 0))
    await sched._catchup_if_missed()

    bot.send_message.assert_awaited()


async def test_catchup_skips_when_already_sent_today(db, monkeypatch):
    bot = MagicMock()
    bot.send_message = AsyncMock()
    sched = SummaryScheduler(db=db, bot=bot, owner_id=42, tz=TZ)
    today = date(2026, 5, 8)
    now_local = _dt(2026, 5, 8, 10, 0)
    monkeypatch.setattr("tasks_bot.scheduler.today_tz", lambda tz: today)
    monkeypatch.setattr("tasks_bot.scheduler.now_tz", lambda tz: now_local)

    await db.save_summary("morning", {}, now=_dt(2026, 5, 8, 9, 0))
    await sched._catchup_if_missed()

    bot.send_message.assert_not_awaited()


async def test_run_stops_cleanly(db, monkeypatch):
    bot = MagicMock()
    bot.send_message = AsyncMock()
    sched = SummaryScheduler(db=db, bot=bot, owner_id=42, tz=TZ)
    monkeypatch.setattr("tasks_bot.scheduler.today_tz",
                         lambda tz: date(2026, 5, 8))
    monkeypatch.setattr("tasks_bot.scheduler.now_tz",
                         lambda tz: _dt(2026, 5, 8, 10, 0))

    task = asyncio.create_task(sched.run())
    await asyncio.sleep(0.05)
    sched.stop()
    await asyncio.wait_for(task, timeout=2.0)
```

- [ ] **Step 2: Прогнать — упадут**

```bash
pytest tests/test_scheduler.py -v -k "catchup or run_stops"
```

- [ ] **Step 3: Реализация — дописать в класс `SummaryScheduler`**

```python
    async def _catchup_if_missed(self):
        now = now_tz(self.tz)
        for event_type in (EventType.MORNING, EventType.EVENING, EventType.WEEKLY):
            saved = await self.db.load_summary(event_type.value)
            last_at = None
            if saved is not None:
                row = await self.db.fetchone(
                    "SELECT created_at FROM last_summary WHERE summary_type = ?",
                    (event_type.value,),
                )
                if row:
                    last_at = datetime.fromisoformat(row["created_at"])
            if is_period_missed(event_type, now, last_at):
                await self._send_summary(event_type)

    async def run(self):
        await self._catchup_if_missed()
        while not self._stopped:
            now = now_tz(self.tz)
            event = compute_next_event(now)
            wait_seconds = (event.when - now).total_seconds()
            # Spin in small chunks so stop() reacts quickly
            while wait_seconds > 0 and not self._stopped:
                step = min(wait_seconds, 5.0)
                await asyncio.sleep(step)
                wait_seconds -= step
            if self._stopped:
                break
            await self._send_summary(event.type)
```

- [ ] **Step 4: Прогнать**

```bash
pytest tests/test_scheduler.py -v
```
Expected: 18 passed.

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/scheduler.py tests/test_scheduler.py
git commit -m "feat: add scheduler run loop with catchup"
```

---

# Task 12: Команда `/today`

**Files:**
- Modify: `src/tasks_bot/bot.py`
- Test: `tests/test_bot_commands.py` (создать)

- [ ] **Step 1: Тесты**

Создать `tests/test_bot_commands.py`:

```python
import pytest
from datetime import date, datetime
from unittest.mock import AsyncMock, MagicMock, patch
from tasks_bot.models import TaskStatus, DeadlineKind
from tasks_bot.bot import handle_today_logic


@pytest.fixture(autouse=True)
def mock_settings(monkeypatch):
    monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test")
    monkeypatch.setenv("TELEGRAM_OWNER_USER_ID", "123")
    monkeypatch.setenv("OPENROUTER_API_KEY", "key")
    monkeypatch.setenv("DATA_REPO_URL", "git@x:y.git")
    monkeypatch.setenv("DATA_REPO_PATH", "/tmp/r")
    monkeypatch.setenv("DB_PATH", "/tmp/t.db")
    from tasks_bot.config import get_settings
    get_settings.cache_clear()
    yield
    get_settings.cache_clear()


async def test_today_logic_builds_morning_and_saves_manual(db):
    today = date(2026, 5, 8)
    now = datetime(2026, 5, 8, 14, 0, 0)
    tid = await db.insert_task(text="позвонить", project="mars",
                                 deadline=today, deadline_kind=DeadlineKind.DATE,
                                 status=TaskStatus.CONFIRMED, now=now)
    text, mapping = await handle_today_logic(db, today, now)
    assert "На сегодня" in text
    assert "позвонить" in text
    saved = await db.load_summary("manual")
    assert saved == {1: tid}
```

- [ ] **Step 2: Прогнать — упадёт**

```bash
pytest tests/test_bot_commands.py::test_today_logic_builds_morning_and_saves_manual -v
```

- [ ] **Step 3: Реализация в `bot.py`**

В шапку `bot.py` добавить:

```python
from telegram.ext import CommandHandler
from .summaries import (
    build_morning, build_evening, build_overdue, Summary,
)
```

В тело `bot.py` добавить:

```python
async def handle_today_logic(db, today, now) -> tuple[str, dict]:
    summary = build_morning(
        today=await db.fetch_due_today(today),
        week=await db.fetch_due_this_week(today),
        no_deadline=await db.fetch_no_deadline(),
        today_date=today,
    )
    await db.save_summary("manual", summary.numbers_to_ids, now=now)
    return summary.text, summary.numbers_to_ids


async def handle_today_command(update, context):
    if not is_owner(update, context.application.bot_data["owner_id"]):
        return
    db = context.application.bot_data["db"]
    settings = get_settings()
    today = today_tz(settings.timezone)
    now = now_tz(settings.timezone)
    text, _ = await handle_today_logic(db, today, now)
    await update.message.reply_text(text)
```

- [ ] **Step 4: Прогнать**

```bash
pytest tests/test_bot_commands.py -v
```

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/bot.py tests/test_bot_commands.py
git commit -m "feat: add /today command handler"
```

---

# Task 13: Команда `/overdue`

**Files:**
- Modify: `src/tasks_bot/bot.py`
- Test: `tests/test_bot_commands.py`

- [ ] **Step 1: Тесты**

```python
from tasks_bot.bot import handle_overdue_logic
from datetime import timedelta


async def test_overdue_logic_with_tasks(db):
    today = date(2026, 5, 8)
    now = datetime(2026, 5, 8, 10, 0, 0)
    tid = await db.insert_task(text="вчерашняя", project="mars",
                                 deadline=today - timedelta(days=1),
                                 deadline_kind=DeadlineKind.DATE,
                                 status=TaskStatus.CONFIRMED, now=now)
    text, mapping = await handle_overdue_logic(db, today, now)
    assert "Просроченные" in text
    assert "вчерашняя" in text
    saved = await db.load_summary("overdue")
    assert saved == {1: tid}


async def test_overdue_logic_empty(db):
    today = date(2026, 5, 8)
    now = datetime(2026, 5, 8, 10, 0, 0)
    text, mapping = await handle_overdue_logic(db, today, now)
    assert "Просроченных нет" in text
    assert mapping == {}
```

- [ ] **Step 2: Прогнать — упадёт**

```bash
pytest tests/test_bot_commands.py -v -k overdue
```

- [ ] **Step 3: Реализация**

В `bot.py`:

```python
async def handle_overdue_logic(db, today, now) -> tuple[str, dict]:
    summary = build_overdue(
        overdue=await db.fetch_overdue(today),
        today_date=today,
    )
    await db.save_summary("overdue", summary.numbers_to_ids, now=now)
    return summary.text, summary.numbers_to_ids


async def handle_overdue_command(update, context):
    if not is_owner(update, context.application.bot_data["owner_id"]):
        return
    db = context.application.bot_data["db"]
    settings = get_settings()
    today = today_tz(settings.timezone)
    now = now_tz(settings.timezone)
    text, _ = await handle_overdue_logic(db, today, now)
    await update.message.reply_text(text)
```

- [ ] **Step 4: Прогнать**

```bash
pytest tests/test_bot_commands.py -v
```

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/bot.py tests/test_bot_commands.py
git commit -m "feat: add /overdue command handler"
```

---

# Task 14: Команда `/done` со всеми edge cases

**Files:**
- Modify: `src/tasks_bot/bot.py`
- Test: `tests/test_bot_commands.py`

- [ ] **Step 1: Тесты**

```python
from tasks_bot.bot import handle_done_logic, parse_done_args


def test_parse_done_args_single():
    assert parse_done_args("1") == [1]
    assert parse_done_args("42") == [42]


def test_parse_done_args_multi():
    assert parse_done_args("1,2,3") == [1, 2, 3]
    assert parse_done_args(" 1 , 2 ,3 ") == [1, 2, 3]


def test_parse_done_args_empty():
    assert parse_done_args("") == []
    assert parse_done_args("   ") == []


def test_parse_done_args_invalid():
    with pytest.raises(ValueError):
        parse_done_args("abc")


async def test_done_logic_closes_tasks(db):
    today = date(2026, 5, 8)
    now = datetime(2026, 5, 8, 10, 0, 0)
    tid1 = await db.insert_task(text="t1", project="mars", deadline=today,
                                  deadline_kind=DeadlineKind.DATE,
                                  status=TaskStatus.CONFIRMED, now=now)
    tid2 = await db.insert_task(text="t2", project="mars", deadline=today,
                                  deadline_kind=DeadlineKind.DATE,
                                  status=TaskStatus.CONFIRMED, now=now)
    await db.save_summary("manual", {1: tid1, 2: tid2}, now=now)

    text = await handle_done_logic(db, [1, 2], now)
    assert "Закрыто 2" in text
    t1 = await db.get_task(tid1)
    t2 = await db.get_task(tid2)
    assert t1.status == TaskStatus.DONE
    assert t2.status == TaskStatus.DONE


async def test_done_logic_no_summary(db):
    now = datetime(2026, 5, 8, 10, 0, 0)
    text = await handle_done_logic(db, [1], now)
    assert "Сначала вызови /today" in text


async def test_done_logic_unknown_number(db):
    now = datetime(2026, 5, 8, 10, 0, 0)
    tid = await db.insert_task(text="t", project="mars", deadline=date(2026, 5, 8),
                                 deadline_kind=DeadlineKind.DATE,
                                 status=TaskStatus.CONFIRMED, now=now)
    await db.save_summary("manual", {1: tid}, now=now)
    text = await handle_done_logic(db, [99], now)
    assert "не найдено" in text or "нет номера" in text


async def test_done_logic_already_done(db):
    now = datetime(2026, 5, 8, 10, 0, 0)
    tid = await db.insert_task(text="t", project="mars", deadline=date(2026, 5, 8),
                                 deadline_kind=DeadlineKind.DATE,
                                 status=TaskStatus.CONFIRMED, now=now)
    await db.update_status(tid, TaskStatus.DONE, now=now)
    await db.save_summary("manual", {1: tid}, now=now)
    text = await handle_done_logic(db, [1], now)
    assert "уже" in text.lower()


async def test_done_logic_uses_latest_summary(db):
    today = date(2026, 5, 8)
    older = datetime(2026, 5, 8, 9, 0, 0)
    newer = datetime(2026, 5, 8, 14, 0, 0)
    t_old = await db.insert_task(text="old", project="mars", deadline=today,
                                   deadline_kind=DeadlineKind.DATE,
                                   status=TaskStatus.CONFIRMED, now=older)
    t_new = await db.insert_task(text="new", project="mars", deadline=today,
                                   deadline_kind=DeadlineKind.DATE,
                                   status=TaskStatus.CONFIRMED, now=newer)
    await db.save_summary("morning", {1: t_old}, now=older)
    await db.save_summary("manual", {1: t_new}, now=newer)
    text = await handle_done_logic(db, [1], newer)
    assert "Закрыто" in text
    assert (await db.get_task(t_new)).status == TaskStatus.DONE
    assert (await db.get_task(t_old)).status == TaskStatus.CONFIRMED


async def test_done_logic_empty_args(db):
    now = datetime(2026, 5, 8, 10, 0, 0)
    tid = await db.insert_task(text="t", project="mars", deadline=date(2026, 5, 8),
                                 deadline_kind=DeadlineKind.DATE,
                                 status=TaskStatus.CONFIRMED, now=now)
    await db.save_summary("manual", {1: tid}, now=now)
    text = await handle_done_logic(db, [], now)
    assert "Используй" in text or "/done N" in text
```

- [ ] **Step 2: Прогнать — упадёт**

```bash
pytest tests/test_bot_commands.py -v -k done
```

- [ ] **Step 3: Реализация**

В `bot.py`:

```python
def parse_done_args(arg_text: str) -> list[int]:
    s = arg_text.strip()
    if not s:
        return []
    parts = [p.strip() for p in s.split(",") if p.strip()]
    return [int(p) for p in parts]


async def handle_done_logic(db, numbers: list[int], now) -> str:
    if not numbers:
        return "Используй: /done N или /done N,M"

    latest = await db.load_latest_summary()
    if latest is None:
        return "Сначала вызови /today чтобы получить нумерацию."

    _, mapping = latest
    closed: list[str] = []
    already_done: list[int] = []
    not_found: list[int] = []

    for n in numbers:
        tid = mapping.get(n)
        if tid is None:
            not_found.append(n)
            continue
        task = await db.get_task(tid)
        if task.status == TaskStatus.DONE:
            already_done.append(n)
            continue
        await db.update_status(tid, TaskStatus.DONE, now=now)
        closed.append(task.text)

    lines = []
    if closed:
        lines.append(f"✅ Закрыто {len(closed)} задач{'и' if len(closed) > 1 else ''}:")
        for txt in closed:
            lines.append(f"— {txt}")
    if already_done:
        lines.append(f"Уже было закрыто: {', '.join(str(n) for n in already_done)}")
    if not_found:
        lines.append(f"Не найдено в последней сводке: {', '.join(str(n) for n in not_found)}")
    return "\n".join(lines).strip()


async def handle_done_command(update, context):
    if not is_owner(update, context.application.bot_data["owner_id"]):
        return
    db = context.application.bot_data["db"]
    settings = get_settings()
    now = now_tz(settings.timezone)

    arg_text = " ".join(context.args) if context.args else ""
    try:
        numbers = parse_done_args(arg_text)
    except ValueError:
        await update.message.reply_text("Не понимаю номера. Пример: /done 1,2,3")
        return

    text = await handle_done_logic(db, numbers, now)
    await update.message.reply_text(text)
```

- [ ] **Step 4: Прогнать**

```bash
pytest tests/test_bot_commands.py -v
```

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/bot.py tests/test_bot_commands.py
git commit -m "feat: add /done command with edge cases"
```

---

# Task 15: Callback «Перенести всё»

**Files:**
- Modify: `src/tasks_bot/bot.py`
- Test: `tests/test_bot_commands.py`

- [ ] **Step 1: Тесты**

```python
from tasks_bot.bot import handle_defer_all_logic


async def test_defer_all_with_today_tasks(db):
    today = date(2026, 5, 8)
    now = datetime(2026, 5, 8, 21, 0, 0)
    await db.insert_task(text="a", project="mars", deadline=today,
                          deadline_kind=DeadlineKind.DATE,
                          status=TaskStatus.CONFIRMED, now=now)
    await db.insert_task(text="b", project="life", deadline=today,
                          deadline_kind=DeadlineKind.DATE,
                          status=TaskStatus.CONFIRMED, now=now)
    text = await handle_defer_all_logic(db, today, now)
    assert "2" in text
    assert "завтра" in text.lower()
    rows = await db.fetchall("SELECT text, deadline FROM tasks ORDER BY id")
    assert all(r["deadline"] == "2026-05-09" for r in rows)


async def test_defer_all_when_nothing(db):
    today = date(2026, 5, 8)
    now = datetime(2026, 5, 8, 21, 0, 0)
    text = await handle_defer_all_logic(db, today, now)
    assert "нечего" in text.lower() or "уже" in text.lower()
```

- [ ] **Step 2: Прогнать — упадут**

```bash
pytest tests/test_bot_commands.py -v -k defer
```

- [ ] **Step 3: Реализация**

В `bot.py`:

```python
async def handle_defer_all_logic(db, today, now) -> str:
    count = await db.defer_today_to_tomorrow(today, now=now)
    if count == 0:
        return "Уже перенесено или нечего переносить."
    word = "задачу" if count == 1 else ("задачи" if count < 5 else "задач")
    return f"Перенесла {count} {word} на завтра."
```

И расширить `handle_callback_logic` (внутри функции, перед последним `return`):

```python
    if op == "defer_all":
        today = today_tz(get_settings().timezone)
        text = await handle_defer_all_logic(db, today, now)
        return CallbackResult(text=text, keyboard=None)
```

Внимание: `handle_callback_logic` парсит `data` через `parts = data.split(":")` и `tid = int(parts[1])`. Нужно подправить так, чтобы для `defer_all` (без `:N`) не падать. Поправить начало функции:

```python
async def handle_callback_logic(db, data: str, today: date) -> CallbackResult:
    parts = data.split(":")
    op = parts[0]
    now = now_tz(get_settings().timezone)

    if op == "defer_all":
        text = await handle_defer_all_logic(db, today, now)
        return CallbackResult(text=text, keyboard=None)

    tid = int(parts[1])
    # ... остальная логика без изменений
```

- [ ] **Step 4: Прогнать**

```bash
pytest tests/test_bot_commands.py -v
pytest tests/test_bot.py -v  # старые тесты не должны сломаться
```

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/bot.py tests/test_bot_commands.py
git commit -m "feat: add defer_all callback for evening summary"
```

---

# Task 16: Регистрация команд в `build_application`

**Files:**
- Modify: `src/tasks_bot/bot.py`

- [ ] **Step 1: Тест регистрации**

В `tests/test_bot.py` или `test_bot_commands.py` добавить:

```python
def test_build_application_registers_commands(db):
    from tasks_bot.bot import build_application
    app = build_application(token="test", owner_id=42, db=db)
    handler_types = [type(h).__name__ for group in app.handlers.values()
                      for h in group]
    assert handler_types.count("CommandHandler") >= 3
```

- [ ] **Step 2: Прогнать — упадёт (handlers ещё не зарегистрированы)**

- [ ] **Step 3: Реализация**

В функции `build_application` (в `bot.py`) добавить перед финальным `return`:

```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))
```

- [ ] **Step 4: Прогнать**

```bash
pytest tests/test_bot.py tests/test_bot_commands.py -v
```

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/bot.py tests/test_bot_commands.py
git commit -m "feat: register /today /overdue /done command handlers"
```

---

# Task 17: Подключить scheduler в `main.py` + `set_my_commands`

**Files:**
- Modify: `src/tasks_bot/main.py`

- [ ] **Step 1: Прочитать текущий `main.py`**

Файл `src/tasks_bot/main.py` имеет функцию `main()` с запуском sync/backup воркеров и application. Дописать туда scheduler.

- [ ] **Step 2: Реализация**

Заменить содержимое `main.py` на:

```python
import asyncio
import logging
from telegram import BotCommand
from .config import get_settings
from .db import Database
from .bot import build_application
from .git_sync import GitRepo, SyncWorker, BackupWorker
from .scheduler import SummaryScheduler

logging.basicConfig(level=logging.INFO,
                     format="%(asctime)s %(levelname)s %(name)s %(message)s")


BOT_COMMANDS = [
    BotCommand("today", "Показать сводку на сейчас"),
    BotCommand("overdue", "Показать просроченные"),
    BotCommand("done", "Закрыть задачи: /done 1 или /done 1,2"),
]


async def main():
    settings = get_settings()
    db = Database(settings.db_path)
    await db.connect()
    await db.migrate()

    repo = GitRepo(settings.data_repo_path)
    sync = SyncWorker(db, repo, interval_seconds=5)
    backup = BackupWorker(settings.db_path, repo, interval_seconds=3600)

    app = build_application(settings.telegram_bot_token,
                            settings.telegram_owner_user_id, db=db)

    scheduler = SummaryScheduler(db=db, bot=app.bot,
                                  owner_id=settings.telegram_owner_user_id,
                                  tz=settings.timezone)

    sync_task = asyncio.create_task(sync.run())
    backup_task = asyncio.create_task(backup.run())

    try:
        await app.initialize()
        await app.start()
        await app.bot.set_my_commands(BOT_COMMANDS)
        await app.updater.start_polling()

        scheduler_task = asyncio.create_task(scheduler.run())
        try:
            await asyncio.Future()
        finally:
            scheduler.stop()
            await scheduler_task
    finally:
        sync.stop()
        backup.stop()
        await sync_task
        await backup_task
        await app.updater.stop()
        await app.stop()
        await app.shutdown()
        await db.close()


def run():
    asyncio.run(main())


if __name__ == "__main__":
    run()
```

- [ ] **Step 3: Прогнать всё**

```bash
pytest -v
```
Expected: 62 + ~30 = ~92 passed.

- [ ] **Step 4: Smoke-test локально (на маке)**

```bash
# Проверяем что импорты не сломаны
python -c "from tasks_bot.main import main, BOT_COMMANDS; print('ok', len(BOT_COMMANDS))"
```
Expected: `ok 3`

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/main.py
git commit -m "feat: wire SummaryScheduler and set_my_commands in main"
```

---

# Task 18: Деплой на Pi

> На Pi код подтягивается через `git pull`, бот рестартуется через systemd. Сетевой трафик идёт через proxychains+Mihomo.

- [ ] **Step 1: Запушить локально, проверить что CI/тесты прошли**

```bash
git push origin main  # или нужная ветка
```

- [ ] **Step 2: SSH на Pi и pull**

```bash
ssh rasp@raspberrypi.local
cd ~/tasks_bot
proxychains4 git pull
```

Expected: `Already up to date.` или fast-forward.

- [ ] **Step 3: Прогнать тесты на Pi (опционально, но желательно)**

```bash
source .venv/bin/activate
pytest -v
```

Expected: ~92 passed.

- [ ] **Step 4: Перезапустить сервис**

```bash
sudo systemctl restart tasks-bot
sudo systemctl status tasks-bot
```

Expected: `active (running)`. В логах не должно быть traceback.

- [ ] **Step 5: Проверить логи и работоспособность**

```bash
sudo journalctl -u tasks-bot -f
```

Должно появиться:
- Без ошибок при старте
- Успешный `set_my_commands` (HTTP 200)
- Догон сводок если были пропущены

В Telegram:
- В меню команд (слева от поля ввода) появляются `/today`, `/overdue`, `/done`
- `/today` присылает сводку
- `/overdue` — просроченные или «нет»
- `/done` без аргументов — подсказку

- [ ] **Step 6: Коммит и обновление статуса плана**

В файле спека `{vibe-coding} {plan} tasks-bot план-3.1 расписание – 2026-05-08.md` обновить frontmatter: `status: implemented`. То же — для этого файла.

```bash
git add docs/
git commit -m "docs: mark Plan 3.1 as implemented"
git push
```

---

## Self-review

После завершения всех задач проверить:

1. **Покрытие спека**: все edge cases из спека закрыты тестами?
   - [ ] Догон при простое — Task 9, 11
   - [ ] `/done` без сводки — Task 14
   - [ ] `/done` с невалидными номерами — Task 14
   - [ ] `/done` уже-done — Task 14
   - [ ] «Перенести всё» когда нечего — Task 15
   - [ ] Часовой пояс — все builders и handlers принимают `today` явно

2. **Никаких заглушек** — все методы реализованы, тесты не помечены `skip`/`xfail`.

3. **Коммиты** — после каждой задачи отдельный коммит, история читается.

4. **Тесты**: 92+ зелёные.

---

## Out of scope (Plan 3.2, отдельный план)

- Команды воскресного управления `/this N`, `/skip N`, `/del N`
- Уведомления о приближающихся дедлайнах
- Голосовой ввод

После завершения 3.1 — обновить `AGENTS.md` папки и `MEMORY.md` пользователя.
