---
project: vibe-coding
type: plan
date: 2026-06-01
status: ready-to-execute
related_spec: "{vibe-coding} {plan} tasks-bot 2.0 ticktick – 2026-05-30.md"
related_plan: "{vibe-coding} {plan} tasks-bot 2.0 core имплементация – 2026-05-31.md"
---

# tasks-bot 2.0 — Scheduler Implementation Plan (План 3)

> **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:** добавить автоматические сводки в Telegram по расписанию (TZ Asia/Yekaterinburg): утренняя 09:00, вечерняя 21:00 с кнопкой «Перенести всё», воскресная celebration 18:00 с похвалой. Реализует раздел «Summary scheduler» спека v2.0.

**Архитектура:** новый `src/tasks_bot/v2/scheduler.py` — async-цикл, который по cron-расписанию вызывает уже готовые методы `Commands` (`today`) и новые методы. Celebration читает `events.jsonl` через расширенный `EventLog`. Кнопка «Перенести всё» переиспользует `SummaryCache` (с kind=`evening`) и вызывает новый `Commands.defer_all`. Запускается из `main._run_v2` как `asyncio.create_task`. Никакие v1-компоненты не нужны.

**Tech Stack:** Python 3.11, python-telegram-bot 21 (async), asyncio, zoneinfo, существующие httpx/aiosqlite/pydantic-settings.

**Не в скоупе:**
- Migration v1→TickTick — План 4
- Monthly archive — План 5
- Evals — План 6
- v1 cleanup — План 7

---

## File Structure

| File | Responsibility |
|------|----------------|
| `src/tasks_bot/v2/event_log.py` (modify) | Добавить `read_completed_for_period(from_dt, to_dt)` для чтения closed-задач из jsonl |
| `src/tasks_bot/v2/celebration.py` (new) | Чистый форматтер celebration-сообщения из списка closed-entries |
| `src/tasks_bot/v2/commands.py` (modify) | Добавить `defer_all(user_id, target_date, now)` — переносит все задачи из кэша kind=`evening` на target_date |
| `src/tasks_bot/v2/scheduler.py` (new) | `Scheduler` class — async loop, тикает каждую минуту, вызывает morning/evening/sunday в нужное время |
| `src/tasks_bot/v2/app.py` (modify) | Callback handler для inline-кнопки «Перенести всё» |
| `src/tasks_bot/bot.py` (modify) | Инстанцировать Scheduler в v2-ветке, сохранить в `bot_data["v2_scheduler"]` |
| `src/tasks_bot/main.py` (modify) | В `_run_v2` запустить `asyncio.create_task(scheduler.run())`, остановить в finally |
| `tests/v2/test_event_log_read.py` | Tests для `read_completed_for_period` |
| `tests/v2/test_celebration.py` | Tests для celebration формата |
| `tests/v2/test_commands.py` (modify) | Тест для `defer_all` |
| `tests/v2/test_scheduler.py` | Tests для логики тиков (фиксируем «now» через monkeypatch) |

---

## Task 1: EventLog.read_completed_for_period

**Files:**
- Modify: `src/tasks_bot/v2/event_log.py`
- Create: `tests/v2/test_event_log_read.py`

Добавляем чтение event-log за период. Каждая complete-запись = одна закрытая задача в окне.

- [ ] **Step 1.1: Write tests first**

`tests/v2/test_event_log_read.py`:

```python
import json
from datetime import datetime, timezone, timedelta
from pathlib import Path

import pytest

from tasks_bot.v2.event_log import EventLog, CompletedEntry


def test_read_returns_only_completes_in_window(tmp_path: Path):
    log_path = tmp_path / "events.jsonl"
    base = datetime(2026, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
    log = EventLog(log_path)
    # write a complete BEFORE window
    log.write(op="complete", task_id="old", ts=base - timedelta(days=10),
              title="старая", brand="mars", list_name="To Do")
    # write a non-complete in window (should be ignored)
    log.write(op="create", task_id="x", ts=base,
              title="создана", brand="life", list_name="To Do")
    # write a complete in window
    log.write(op="complete", task_id="a", ts=base + timedelta(hours=1),
              title="налоги", brand="life", list_name="To Do")
    # write a complete AFTER window
    log.write(op="complete", task_id="late", ts=base + timedelta(days=10),
              title="поздняя", brand="mars", list_name="To Do")

    window_start = base
    window_end = base + timedelta(days=7)
    results = log.read_completed_for_period(window_start, window_end)

    assert len(results) == 1
    assert isinstance(results[0], CompletedEntry)
    assert results[0].task_id == "a"
    assert results[0].title == "налоги"
    assert results[0].brand == "life"
    assert results[0].list_name == "To Do"


def test_read_empty_file_returns_empty_list(tmp_path: Path):
    log_path = tmp_path / "events.jsonl"
    log_path.touch()
    log = EventLog(log_path)
    results = log.read_completed_for_period(
        datetime(2026, 6, 1, tzinfo=timezone.utc),
        datetime(2026, 6, 8, tzinfo=timezone.utc),
    )
    assert results == []


def test_read_missing_file_returns_empty_list(tmp_path: Path):
    log = EventLog(tmp_path / "nonexistent.jsonl")
    results = log.read_completed_for_period(
        datetime(2026, 6, 1, tzinfo=timezone.utc),
        datetime(2026, 6, 8, tzinfo=timezone.utc),
    )
    assert results == []


def test_read_skips_malformed_lines(tmp_path: Path):
    log_path = tmp_path / "events.jsonl"
    log_path.write_text(
        "not json\n"
        '{"ts": "2026-06-01T12:00:00+00:00", "op": "complete", "task_id": "a", '
        '"title": "ok", "brand": "life", "list": "To Do"}\n',
        encoding="utf-8",
    )
    log = EventLog(log_path)
    results = log.read_completed_for_period(
        datetime(2026, 6, 1, tzinfo=timezone.utc),
        datetime(2026, 6, 2, tzinfo=timezone.utc),
    )
    assert len(results) == 1
    assert results[0].task_id == "a"
```

- [ ] **Step 1.2: Run — should fail**

Run: `.venv/bin/pytest tests/v2/test_event_log_read.py -v`
Expected: FAIL (CompletedEntry / read_completed_for_period not defined).

- [ ] **Step 1.3: Extend event_log.py**

Read `src/tasks_bot/v2/event_log.py` first. Append the new dataclass and method:

```python
from dataclasses import dataclass
from datetime import datetime


@dataclass
class CompletedEntry:
    task_id: str
    title: str
    brand: str
    list_name: str
    ts: datetime
```

Add as a method on `EventLog` class:

```python
    def read_completed_for_period(
        self,
        window_start: datetime,
        window_end: datetime,
    ) -> list[CompletedEntry]:
        """Вернуть все complete-записи с window_start ≤ ts ≤ window_end.

        Пропускает строки, которые не парсятся как JSON, и не-complete операции.
        Если файла нет — возвращает пустой список.
        """
        import json as _json

        if not self._path.exists():
            return []
        results: list[CompletedEntry] = []
        for raw_line in self._path.read_text(encoding="utf-8").splitlines():
            line = raw_line.strip()
            if not line:
                continue
            try:
                record = _json.loads(line)
            except _json.JSONDecodeError:
                continue
            if record.get("op") != "complete":
                continue
            try:
                ts = datetime.fromisoformat(record["ts"])
            except (KeyError, ValueError):
                continue
            if not (window_start <= ts <= window_end):
                continue
            results.append(CompletedEntry(
                task_id=record.get("task_id", ""),
                title=record.get("title", ""),
                brand=record.get("brand", ""),
                list_name=record.get("list", ""),
                ts=ts,
            ))
        return results
```

- [ ] **Step 1.4: Tests pass**

Run: `.venv/bin/pytest tests/v2/test_event_log_read.py -v` → 4 passed.
Run: `.venv/bin/pytest 2>&1 | tail -3` → 326 passed (322 + 4 new).

- [ ] **Step 1.5: Commit**

```bash
git add src/tasks_bot/v2/event_log.py tests/v2/test_event_log_read.py
git commit -m "v2: EventLog.read_completed_for_period for celebration"
```

---

## Task 2: Celebration formatter

**Files:**
- Create: `src/tasks_bot/v2/celebration.py`
- Create: `tests/v2/test_celebration.py`

Чистая функция. Принимает list[CompletedEntry] и форматирует поздравление.

- [ ] **Step 2.1: Write tests**

`tests/v2/test_celebration.py`:

```python
from datetime import datetime, timezone, timedelta

from tasks_bot.v2.celebration import format_celebration
from tasks_bot.v2.event_log import CompletedEntry


YEKB = timezone(timedelta(hours=5))


def _e(title: str, brand: str, list_name: str = "To Do") -> CompletedEntry:
    return CompletedEntry(
        task_id="t",
        title=title,
        brand=brand,
        list_name=list_name,
        ts=datetime(2026, 5, 28, 12, 0, tzinfo=YEKB),
    )


def test_empty_week():
    text = format_celebration([])
    assert "пусто" in text.lower() or "ничего" in text.lower()


def test_groups_by_brand_in_fixed_order():
    entries = [
        _e("c1", "channel"),
        _e("m1", "mars"),
        _e("l1", "life"),
    ]
    text = format_celebration(entries)
    # mars впереди channel впереди life
    assert text.index("mars") < text.index("channel")
    assert text.index("channel") < text.index("life")
    # Все три заголовка задач присутствуют
    assert "m1" in text and "c1" in text and "l1" in text


def test_includes_count_per_brand():
    entries = [
        _e("m1", "mars"),
        _e("m2", "mars"),
        _e("m3", "mars"),
        _e("c1", "channel"),
    ]
    text = format_celebration(entries)
    assert "(3)" in text  # mars
    assert "(1)" in text  # channel


def test_includes_total_and_praise():
    entries = [_e("x", "life"), _e("y", "life")]
    text = format_celebration(entries)
    assert "2" in text  # total count
    assert "молодец" in text.lower() or "🎉" in text or "💪" in text


def test_unknown_brand_grouped_separately():
    entries = [_e("x", "mars"), _e("y", "unknown_brand")]
    text = format_celebration(entries)
    assert "x" in text
    assert "y" in text  # not silently dropped
```

- [ ] **Step 2.2: Run — fail**

Run: `.venv/bin/pytest tests/v2/test_celebration.py -v`
Expected: FAIL.

- [ ] **Step 2.3: Implementation**

`src/tasks_bot/v2/celebration.py`:

```python
"""Форматтер воскресной celebration-сводки.

Не «обзор предстоящего» (его нет — спек явно отверг). Только радостный
итог: что закрыто за неделю, сгруппировано по проектам, с похвалой.
"""
from tasks_bot.v2.event_log import CompletedEntry

BRAND_ORDER = ["mars", "channel", "consulting", "life", "vibe-coding"]


def format_celebration(entries: list[CompletedEntry]) -> str:
    if not entries:
        return "🌿 За эту неделю ничего не закрыто. Бывает! Новая неделя — новые задачи."

    # Группировка
    by_brand: dict[str, list[CompletedEntry]] = {b: [] for b in BRAND_ORDER}
    other: list[CompletedEntry] = []
    for e in entries:
        if e.brand in by_brand:
            by_brand[e.brand].append(e)
        else:
            other.append(e)

    lines = ["🎉 Неделя закрыта!", ""]
    total = 0
    for brand in BRAND_ORDER:
        rows = by_brand[brand]
        if not rows:
            continue
        lines.append(f"📁 {brand} ({len(rows)}):")
        for e in rows:
            lines.append(f"  ✅ {e.title}")
        lines.append("")
        total += len(rows)
    if other:
        lines.append(f"📁 прочее ({len(other)}):")
        for e in other:
            lines.append(f"  ✅ {e.title}")
        lines.append("")
        total += len(other)

    lines.append(f"Итого {total} задач за неделю. Молодец! 💪")
    return "\n".join(lines).rstrip()
```

- [ ] **Step 2.4: Tests pass**

Run: `.venv/bin/pytest tests/v2/test_celebration.py -v` → 5 passed.

- [ ] **Step 2.5: Commit**

```bash
git add src/tasks_bot/v2/celebration.py tests/v2/test_celebration.py
git commit -m "v2: celebration formatter (Sunday week-close summary)"
```

---

## Task 3: Commands.defer_all

**Files:**
- Modify: `src/tasks_bot/v2/commands.py`
- Modify: `tests/v2/test_commands.py`

`defer_all(user_id, target_date, now)` — переносит все задачи из последней сохранённой evening-сводки (kind="evening") на target_date. Используется callback'ом «Перенести всё».

Для этого нужно добавить метод в `SummaryCache.list_for_user(user_id, kind)` → список task_id. Сначала это.

- [ ] **Step 3.1: Extend SummaryCache**

Read `src/tasks_bot/v2/summary_cache.py`. Append method to `SummaryCache` class:

```python
    async def list_for_user(
        self,
        *,
        user_id: int,
        kind: str,
        now: datetime,
    ) -> list[str]:
        """Вернуть свежие task_id'ы пользователя для данного kind.

        Свежие = с сегодняшней (TZ Екб) датой создания. Если устаревшие —
        возвращает пустой список.
        """
        assert self._db
        async with self._db.execute(
            "SELECT task_id, created_at FROM summary_entries "
            "WHERE user_id = ? AND kind = ? ORDER BY n",
            (user_id, kind),
        ) as cur:
            rows = await cur.fetchall()
        result: list[str] = []
        today = now.astimezone(self._tz).date()
        for task_id, created_str in rows:
            created_at = datetime.fromisoformat(created_str)
            if created_at.astimezone(self._tz).date() == today:
                result.append(task_id)
        return result
```

Note: existing `replace_summary` deletes ALL rows for user (`DELETE FROM summary_entries WHERE user_id = ?`). For evening-summary we need to ADD entries with kind="evening" without wiping morning's "today" entries — OR we accept that evening replaces morning (since morning's numbered list is already used; numbers re-issued).

Решение: меняем `replace_summary` чтобы он удалял только записи с тем же `kind`. Это безопасно: морнинг-кэш не теряется при сохранении evening-кэша.

Replace existing `replace_summary` method with:

```python
    async def replace_summary(
        self,
        *,
        user_id: int,
        kind: str,
        entries: list[tuple[int, str]],
        at: datetime,
    ) -> None:
        assert self._db
        # Удаляем только записи с этим kind для этого пользователя.
        # Записи других kind (например, /today + /backlog) сохраняются.
        await self._db.execute(
            "DELETE FROM summary_entries WHERE user_id = ? AND kind = ?",
            (user_id, kind),
        )
        for n, task_id in entries:
            await self._db.execute(
                "INSERT INTO summary_entries (user_id, n, task_id, kind, created_at) "
                "VALUES (?, ?, ?, ?, ?)",
                (user_id, n, task_id, kind, at.isoformat()),
            )
        await self._db.commit()
```

⚠ Проблема: primary key `(user_id, n)`. Если у пользователя есть и today, и backlog с одинаковыми n — конфликт. Меняем PK на `(user_id, kind, n)`.

В `SCHEMA` constant заменить:

```python
SCHEMA = """
CREATE TABLE IF NOT EXISTS summary_entries (
    user_id INTEGER NOT NULL,
    n INTEGER NOT NULL,
    task_id TEXT NOT NULL,
    kind TEXT NOT NULL,
    created_at TEXT NOT NULL,
    PRIMARY KEY (user_id, kind, n)
);
"""
```

⚠ Существующая DB на Pi уже имеет старый PK. Миграция: при первом запуске после деплоя — таблица будет совместима по полям, но конфликт PK старых данных не возникнет (старые записи останутся, новый PK применится только к новым insert'ам потому что `CREATE TABLE IF NOT EXISTS` не изменяет существующую таблицу).

Чтобы избежать неожиданностей: добавим явный SQL миграции на старте:

В метод `connect()` после `executescript(SCHEMA)`:

```python
        # Миграция: если таблица была создана со старым PK (user_id, n) —
        # удаляем её и пересоздаём.
        async with self._db.execute(
            "SELECT name FROM pragma_table_info('summary_entries') WHERE pk > 0"
        ) as cur:
            pk_cols = [row[0] for row in await cur.fetchall()]
        if pk_cols == ["user_id", "n"]:
            await self._db.execute("DROP TABLE summary_entries")
            await self._db.executescript(SCHEMA)
        await self._db.commit()
```

- [ ] **Step 3.2: Update existing test for resolve_stale (kind-aware delete)**

Tests `test_replace_summary_overwrites_prev` should pass; the assertion `with pytest.raises(KeyError): await cache.resolve(user_id=1, n=2, now=now)` still holds because after second replace_summary kind="today", n=2 row is deleted. Good.

But `test_store_and_resolve_roundtrip` and `test_resolve_stale_raises` need to be reviewed — they call `replace_summary` with kind="today", then `resolve` without specifying kind. `resolve` doesn't take a kind filter (it queries by `user_id` and `n` only). That stays correct because we still expect only one row per (user_id, n) — but actually with new PK (user_id, kind, n), there could be multiple rows with same n across kinds.

Fix `resolve()`: it must filter by kind too, OR we keep N globally unique per user.

Simpler design: keep N unique per user (across kinds) because all command numbers come from the LAST summary sent. Old summaries' N's become stale. So we never want resolve("today's n=3") if user just got an evening summary.

Revised approach: KEEP original `replace_summary` deleting ALL user rows, and store kind only as metadata. For defer-all use a SEPARATE table / store.

Actually simplest: defer_all stores its target list in app memory (a dict on Scheduler instance), keyed by user_id, with timestamp. No DB needed.

Let me revise Step 3.1 + 3.2 to avoid the SummaryCache redesign:

**Revised Step 3.1 (replacing the above 3.1 entirely):**

Add new in-memory store `EveningSnapshot` instead of touching SummaryCache.

Create `src/tasks_bot/v2/evening_snapshot.py`:

```python
"""In-memory снимок вечерней сводки для callback «Перенести всё».

Хранит список task_id, который скедулер показал в вечерней сводке.
При нажатии кнопки — все эти задачи переносятся на завтра.
"""
from dataclasses import dataclass, field
from datetime import datetime, timezone


def _now() -> datetime:
    return datetime.now(timezone.utc)


@dataclass
class EveningSnapshot:
    task_ids: list[str]
    created_at: datetime = field(default_factory=lambda: _now())


class EveningStore:
    """One snapshot per user_id. Evening rewrites previous evening."""

    def __init__(self):
        self._store: dict[int, EveningSnapshot] = {}

    def set(self, user_id: int, task_ids: list[str]) -> None:
        self._store[user_id] = EveningSnapshot(task_ids=list(task_ids))

    def get(self, user_id: int) -> EveningSnapshot | None:
        return self._store.get(user_id)

    def clear(self, user_id: int) -> None:
        self._store.pop(user_id, None)
```

Tests `tests/v2/test_evening_snapshot.py`:

```python
from tasks_bot.v2.evening_snapshot import EveningStore


def test_set_and_get():
    store = EveningStore()
    store.set(1, ["a", "b", "c"])
    snap = store.get(1)
    assert snap is not None
    assert snap.task_ids == ["a", "b", "c"]


def test_set_overrides():
    store = EveningStore()
    store.set(1, ["a"])
    store.set(1, ["b", "c"])
    assert store.get(1).task_ids == ["b", "c"]


def test_get_missing_returns_none():
    store = EveningStore()
    assert store.get(999) is None


def test_clear():
    store = EveningStore()
    store.set(1, ["a"])
    store.clear(1)
    assert store.get(1) is None
```

This avoids SummaryCache surgery entirely. Existing tests stay green.

- [ ] **Step 3.2: Run + commit EveningStore**

Run: `.venv/bin/pytest tests/v2/test_evening_snapshot.py -v` → after creating module, 4 passed.

```bash
git add src/tasks_bot/v2/evening_snapshot.py tests/v2/test_evening_snapshot.py
git commit -m "v2: EveningStore for 'defer all' button payload"
```

- [ ] **Step 3.3: Add Commands.defer_all method**

Append to `Commands` class in `src/tasks_bot/v2/commands.py`:

```python
    async def defer_all(
        self,
        *,
        task_ids: list[str],
        target_date: date,
        now: datetime,
    ) -> str:
        """Перенести все task_ids на target_date.

        Возвращает текстовый отчёт. Каждая задача проверяется (re-check) перед
        мутацией; пропущенные считаются.
        """
        from tasks_bot.v2.dueformat import format_due

        deferred = 0
        skipped = 0
        for task_id in task_ids:
            task = await self._tk.get(f"task/{task_id}")
            if not task or task.get("status", 0) != 0:
                skipped += 1
                continue
            body = {
                "id": task_id,
                "projectId": task["projectId"],
                "dueDate": format_due(target_date, self._tz),
            }
            await self._tk.post(f"task/{task_id}", body)
            if self._event_log:
                brand_list = self._mapping.reverse(task["projectId"])
                self._event_log.write(
                    op="update",
                    task_id=task_id,
                    ts=now,
                    title=task.get("title", ""),
                    brand=brand_list[0] if brand_list else "?",
                    list_name=brand_list[1] if brand_list else "?",
                    extra={"due": target_date.isoformat()},
                )
            deferred += 1

        if deferred == 0 and skipped == 0:
            return "Нечего переносить."
        if skipped == 0:
            return f"📅 перенесла {deferred} на {target_date.isoformat()}"
        return f"📅 перенесла {deferred}, пропустила {skipped} (уже закрыты/удалены)"
```

- [ ] **Step 3.4: Add test for defer_all**

Append to `tests/v2/test_commands.py`:

```python
@pytest.mark.asyncio
async def test_defer_all_reschedules_multiple_tasks(tmp_path):
    mapping = ProjectMapping.load(FIXTURE)
    ticktick = MagicMock()
    async def get(path):
        if path == "task/a":
            return {"id": "a", "projectId": "p_li_todo", "status": 0, "title": "x"}
        if path == "task/b":
            return {"id": "b", "projectId": "p_mars_todo", "status": 0, "title": "y"}
        if path == "task/closed":
            return {"id": "closed", "projectId": "p_li_todo", "status": 2, "title": "z"}
        return None
    ticktick.get = AsyncMock(side_effect=get)
    ticktick.post = AsyncMock(return_value=None)
    from tasks_bot.v2.event_log import EventLog
    from tasks_bot.v2.summary_cache import SummaryCache
    cache = SummaryCache(tmp_path / "c.sqlite", tz=YEKB)
    await cache.connect()
    try:
        cmds = Commands(ticktick=ticktick, mapping=mapping,
                         summary_cache=cache, tz=YEKB,
                         event_log=EventLog(tmp_path / "e.jsonl"))
        result = await cmds.defer_all(
            task_ids=["a", "b", "closed"],
            target_date=date(2026, 6, 5),
            now=datetime(2026, 6, 1, 21, 0, tzinfo=YEKB),
        )
        assert "2" in result  # deferred 2
        assert "1" in result  # skipped 1
        # Both a and b got POST update
        assert ticktick.post.call_count == 2
    finally:
        await cache.close()
```

- [ ] **Step 3.5: Run + commit**

Run: `.venv/bin/pytest tests/v2/test_commands.py -v` → 8 passed (7 + 1 new).

```bash
git add src/tasks_bot/v2/commands.py tests/v2/test_commands.py
git commit -m "v2: Commands.defer_all for 'перенести всё' button"
```

---

## Task 4: Scheduler class with morning/evening/sunday ticks

**Files:**
- Create: `src/tasks_bot/v2/scheduler.py`
- Create: `tests/v2/test_scheduler.py`

Цикл, который тикает каждую минуту и решает, надо ли отправлять сводку. State: dict `last_fired_for_kind: {kind: date}` — чтобы не отправлять две утренние сводки в один день.

Тики:
- 09:00 любого дня → morning
- 21:00 любого дня → evening
- 18:00 воскресенья → sunday celebration

- [ ] **Step 4.1: Tests**

`tests/v2/test_scheduler.py`:

```python
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock

import pytest

from tasks_bot.v2.scheduler import Scheduler

YEKB = timezone(timedelta(hours=5))


def _now(year=2026, month=6, day=1, hour=9, minute=0):
    return datetime(year, month, day, hour, minute, tzinfo=YEKB)


@pytest.mark.asyncio
async def test_morning_fires_at_09_00():
    morning = AsyncMock()
    evening = AsyncMock()
    sunday = AsyncMock()
    scheduler = Scheduler(
        owner_id=1, tz=YEKB,
        send_morning=morning, send_evening=evening, send_sunday=sunday,
    )
    await scheduler.tick(_now(2026, 6, 1, hour=9, minute=0))
    morning.assert_awaited_once()
    evening.assert_not_called()
    sunday.assert_not_called()


@pytest.mark.asyncio
async def test_morning_does_not_fire_twice_in_one_day():
    morning = AsyncMock()
    scheduler = Scheduler(
        owner_id=1, tz=YEKB,
        send_morning=morning,
        send_evening=AsyncMock(), send_sunday=AsyncMock(),
    )
    await scheduler.tick(_now(2026, 6, 1, hour=9, minute=0))
    await scheduler.tick(_now(2026, 6, 1, hour=9, minute=1))  # one minute later
    morning.assert_awaited_once()  # not twice


@pytest.mark.asyncio
async def test_morning_fires_on_next_day():
    morning = AsyncMock()
    scheduler = Scheduler(
        owner_id=1, tz=YEKB,
        send_morning=morning,
        send_evening=AsyncMock(), send_sunday=AsyncMock(),
    )
    await scheduler.tick(_now(2026, 6, 1, hour=9))  # Monday
    await scheduler.tick(_now(2026, 6, 2, hour=9))  # Tuesday
    assert morning.await_count == 2


@pytest.mark.asyncio
async def test_evening_fires_at_21_00():
    evening = AsyncMock()
    scheduler = Scheduler(
        owner_id=1, tz=YEKB,
        send_morning=AsyncMock(), send_evening=evening, send_sunday=AsyncMock(),
    )
    await scheduler.tick(_now(2026, 6, 1, hour=21, minute=0))
    evening.assert_awaited_once()


@pytest.mark.asyncio
async def test_sunday_fires_at_18_00_on_sunday():
    sunday = AsyncMock()
    scheduler = Scheduler(
        owner_id=1, tz=YEKB,
        send_morning=AsyncMock(), send_evening=AsyncMock(), send_sunday=sunday,
    )
    # 2026-06-07 is Sunday
    await scheduler.tick(_now(2026, 6, 7, hour=18, minute=0))
    sunday.assert_awaited_once()


@pytest.mark.asyncio
async def test_sunday_does_not_fire_on_monday():
    sunday = AsyncMock()
    scheduler = Scheduler(
        owner_id=1, tz=YEKB,
        send_morning=AsyncMock(), send_evening=AsyncMock(), send_sunday=sunday,
    )
    # Monday 18:00
    await scheduler.tick(_now(2026, 6, 1, hour=18, minute=0))
    sunday.assert_not_called()


@pytest.mark.asyncio
async def test_tick_at_random_time_fires_nothing():
    m, e, s = AsyncMock(), AsyncMock(), AsyncMock()
    scheduler = Scheduler(
        owner_id=1, tz=YEKB,
        send_morning=m, send_evening=e, send_sunday=s,
    )
    await scheduler.tick(_now(2026, 6, 1, hour=14, minute=30))
    m.assert_not_called()
    e.assert_not_called()
    s.assert_not_called()
```

- [ ] **Step 4.2: Run — fail**

Run: `.venv/bin/pytest tests/v2/test_scheduler.py -v`
Expected: FAIL.

- [ ] **Step 4.3: Implementation**

`src/tasks_bot/v2/scheduler.py`:

```python
"""Cron-loop для утренней/вечерней/воскресной сводок v2.

Тикает раз в минуту. Каждый тик — решает, надо ли отправить сводку
(сейчас 09:00 / 21:00 любого дня, или 18:00 воскресенья), и не
дублирует ли отправку (проверка по last_fired).
"""
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import date, datetime, timezone
from typing import Awaitable, Callable

log = logging.getLogger(__name__)

MORNING_HOUR = 9
EVENING_HOUR = 21
SUNDAY_CELEBRATION_HOUR = 18
SUNDAY_WEEKDAY = 6  # 0=Mon ... 6=Sun


@dataclass
class _LastFired:
    morning: date | None = None
    evening: date | None = None
    sunday: date | None = None


class Scheduler:
    def __init__(
        self,
        *,
        owner_id: int,
        tz: timezone,
        send_morning: Callable[[], Awaitable[None]],
        send_evening: Callable[[], Awaitable[None]],
        send_sunday: Callable[[], Awaitable[None]],
        tick_interval_seconds: int = 60,
    ):
        self._owner_id = owner_id
        self._tz = tz
        self._send_morning = send_morning
        self._send_evening = send_evening
        self._send_sunday = send_sunday
        self._interval = tick_interval_seconds
        self._fired = _LastFired()
        self._stop_event = asyncio.Event()

    async def tick(self, now: datetime) -> None:
        local = now.astimezone(self._tz)
        today = local.date()

        if local.hour == MORNING_HOUR and self._fired.morning != today:
            log.info("scheduler: morning summary at %s", local.isoformat())
            try:
                await self._send_morning()
            except Exception:  # noqa: BLE001
                log.exception("scheduler: morning send failed")
            self._fired.morning = today
            return

        if local.hour == EVENING_HOUR and self._fired.evening != today:
            log.info("scheduler: evening summary at %s", local.isoformat())
            try:
                await self._send_evening()
            except Exception:
                log.exception("scheduler: evening send failed")
            self._fired.evening = today
            return

        if (
            local.weekday() == SUNDAY_WEEKDAY
            and local.hour == SUNDAY_CELEBRATION_HOUR
            and self._fired.sunday != today
        ):
            log.info("scheduler: sunday celebration at %s", local.isoformat())
            try:
                await self._send_sunday()
            except Exception:
                log.exception("scheduler: sunday send failed")
            self._fired.sunday = today
            return

    async def run(self) -> None:
        log.info("scheduler started (tz=%s, owner=%d)", self._tz, self._owner_id)
        while not self._stop_event.is_set():
            try:
                await self.tick(datetime.now(timezone.utc))
            except Exception:  # noqa: BLE001
                log.exception("scheduler tick failed")
            try:
                await asyncio.wait_for(
                    self._stop_event.wait(), timeout=self._interval,
                )
                break  # stop_event was set
            except asyncio.TimeoutError:
                continue

    def stop(self) -> None:
        self._stop_event.set()
```

- [ ] **Step 4.4: Tests pass**

Run: `.venv/bin/pytest tests/v2/test_scheduler.py -v` → 7 passed.
Run: full suite — expect 338 passed (322 + 4 read + 5 celebration + 4 evening + 1 defer_all + 7 scheduler = 343, but commands.py test was 7→8, so actual: count from terminal).

- [ ] **Step 4.5: Commit**

```bash
git add src/tasks_bot/v2/scheduler.py tests/v2/test_scheduler.py
git commit -m "v2: Scheduler — morning/evening/sunday tick logic"
```

---

## Task 5: Wire scheduler into bot.py + main.py

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

Логика:
- bot.py создаёт `Scheduler` с тремя async-функциями `send_morning/evening/sunday`. Каждая делает: `commands.today()` для morning, `evening_send()` (новый helper) для evening, `commands._send_sunday_summary()` (или прямо в bot.py) для sunday.
- app.py — добавляем callback handler для «перенести всё» кнопки.
- main.py — стартует `asyncio.create_task(scheduler.run())` после `app.initialize()`, останавливает в finally.

- [ ] **Step 5.1: Add callback to v2/app.py**

В `register_v2_handlers`:

```python
    # === callback «Перенести всё» (evening summary) ===
    DEFER_ALL_CALLBACK = "v2defer_all"

    async def on_defer_all(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
        if not _owner_only(update):
            return
        q = update.callback_query
        await q.answer()
        snapshot = ctx.application.bot_data["v2_evening_store"].get(
            update.effective_user.id,
        )
        if snapshot is None:
            await q.edit_message_text("Нет вечерней сводки для переноса.")
            return
        from datetime import timedelta as _td
        tomorrow = datetime.now(tz).astimezone(tz).date() + _td(days=1)
        result = await commands.defer_all(
            task_ids=snapshot.task_ids,
            target_date=tomorrow,
            now=datetime.now(tz),
        )
        ctx.application.bot_data["v2_evening_store"].clear(
            update.effective_user.id,
        )
        await q.edit_message_text(result)
```

Зарегистрировать обработчик в конце `register_v2_handlers`:

```python
    app.add_handler(
        CallbackQueryHandler(on_defer_all, pattern=f"^{DEFER_ALL_CALLBACK}$"),
    )
```

И экспортнуть `DEFER_ALL_CALLBACK` для использования в scheduler:

В начало модуля, после `CALLBACK_PREFIX = "v2pick:"`, добавить:
```python
DEFER_ALL_CALLBACK = "v2defer_all"
```

В функции `on_defer_all` использовать локальную ссылку на этот константный модульный.

- [ ] **Step 5.2: Modify bot.py — instantiate scheduler + store**

Read `src/tasks_bot/bot.py`'s v2 branch. After `commands = cmd_mod.Commands(...)`, add:

```python
        from tasks_bot.v2.evening_snapshot import EveningStore
        from tasks_bot.v2.scheduler import Scheduler
        from tasks_bot.v2.celebration import format_celebration
        from telegram import InlineKeyboardButton, InlineKeyboardMarkup
        from tasks_bot.v2.app import DEFER_ALL_CALLBACK

        evening_store = EveningStore()

        async def _send_morning() -> None:
            today = datetime.now(tz).date()
            text, _ids = await commands.today(user_id=owner_id, today=today)
            if not text:
                return
            await app.bot.send_message(chat_id=owner_id, text=text)

        async def _send_evening() -> None:
            today = datetime.now(tz).date()
            # Берём сегодняшние To Do задачи (как утренняя сводка)
            # и фильтруем те, что ещё открыты.
            text, ids = await commands.today(user_id=owner_id, today=today)
            if not ids:
                return  # нечего показывать
            evening_store.set(owner_id, ids)
            kb = InlineKeyboardMarkup([[
                InlineKeyboardButton(
                    "📅 Перенести всё на завтра",
                    callback_data=DEFER_ALL_CALLBACK,
                ),
            ]])
            await app.bot.send_message(chat_id=owner_id, text=text, reply_markup=kb)

        async def _send_sunday() -> None:
            from datetime import timedelta as _td
            now_local = datetime.now(tz)
            # Прошедшая неделя: с понедельника 00:00 по now (воскресенье 18:00)
            today_local = now_local.date()
            monday = today_local - _td(days=today_local.weekday())
            window_start = datetime.combine(monday, datetime.min.time(), tzinfo=tz)
            window_end = now_local
            entries = event_log.read_completed_for_period(window_start, window_end)
            text = format_celebration(entries)
            await app.bot.send_message(chat_id=owner_id, text=text)

        scheduler = Scheduler(
            owner_id=owner_id, tz=tz,
            send_morning=_send_morning,
            send_evening=_send_evening,
            send_sunday=_send_sunday,
        )

        app.bot_data["v2_evening_store"] = evening_store
        app.bot_data["v2_scheduler"] = scheduler
```

- [ ] **Step 5.3: Modify main.py — start scheduler in _run_v2**

Read `src/tasks_bot/main.py`. Find `_run_v2`. After `await summary_cache.connect()`, add:

```python
        scheduler = app.bot_data["v2_scheduler"]
        scheduler_task = asyncio.create_task(scheduler.run())
```

In the finally block, before `await summary_cache.close()`, add:

```python
        scheduler.stop()
        await scheduler_task
```

(Be careful about exact placement — read the current `_run_v2` first to see structure.)

- [ ] **Step 5.4: Smoke test for app registration count**

The existing `test_app_smoke.py` checks total handlers ≥ 11. After adding `on_defer_all`, total becomes ≥ 12. Update assertion in `tests/v2/test_app_smoke.py`:

Find: `assert total >= 11`. Change to: `assert total >= 12`.

- [ ] **Step 5.5: Run + commit**

Run: `.venv/bin/pytest 2>&1 | tail -3` → all passed (no regressions).

```bash
git add src/tasks_bot/bot.py src/tasks_bot/main.py src/tasks_bot/v2/app.py \
        tests/v2/test_app_smoke.py
git commit -m "v2: wire scheduler + 'перенести всё' callback in bot/main"
```

---

## Task 6: Deploy to Pi

Без правок кода.

- [ ] **Step 6.1: Push to origin**

```bash
git push origin main 2>&1 | tail -3
```

- [ ] **Step 6.2: Pull and restart on Pi**

```bash
ssh pi "cd ~/tasks_bot && git pull && sudo systemctl restart tasks-bot && sleep 3 && sudo journalctl -u tasks-bot --since '5 sec ago' --no-pager | grep -E 'v2 handlers|scheduler started|Application started'"
```

Expected output должен содержать:
```
INFO tasks_bot.v2.app v2 handlers registered
INFO tasks_bot.v2.scheduler scheduler started (tz=Asia/Yekaterinburg, owner=...)
INFO telegram.ext.Application Application started
```

- [ ] **Step 6.3: Verify next scheduled tick**

В зависимости от текущего времени Екб:
- Если сейчас 08:00-08:59 — следующая сводка в 09:00 (несколько минут)
- Если 20:00-20:59 — следующая в 21:00
- Иначе — следующая в 09:00 завтра

Если хочешь проверить «здесь и сейчас», можно временно изменить `MORNING_HOUR` в `scheduler.py` на текущий час, задеплоить, дождаться тика (одна минута), затем откатить.

---

## Self-Review

**1. Spec coverage:**
- Утренняя 09:00 ✓ (Scheduler + _send_morning + commands.today)
- Вечерняя 21:00 ✓ (Scheduler + _send_evening + кнопка)
- Воскресная celebration ✓ (Scheduler + _send_sunday + celebration.format + event_log.read_completed_for_period)
- «Перенести всё» ✓ (callback + Commands.defer_all + EveningStore)
- Расписание в TZ Екб ✓ (Scheduler принимает tz явно)
- Re-check перед мутацией для defer_all ✓ (внутри метода defer_all)

**Gap:** монотонные нет — все 4 ключевых сценария спека покрыты.

**2. Placeholders:**
- Нет «TBD/TODO/handle later». Все шаги имеют код или конкретный SQL/команду.
- Шаг 5.3 говорит «read the current _run_v2 first to see structure» — это разрешённое чтение существующего кода для встраивания, не плейсхолдер. Точное место правки определяется чтением, код для вставки дан явно.

**3. Type consistency:**
- `CompletedEntry` (определён в event_log.py, использован в celebration.py и bot.py)
- `EveningSnapshot` / `EveningStore` (определены в evening_snapshot.py, использованы в bot.py и app.py)
- `Scheduler.__init__` принимает `send_morning/evening/sunday` как `Callable[[], Awaitable[None]]` — bot.py создаёт три async-функции с такой же сигнатурой ✓
- `Commands.defer_all` принимает `task_ids: list[str], target_date: date, now: datetime` — `on_defer_all` callback передаёт ровно эти типы ✓
- `DEFER_ALL_CALLBACK` константа в app.py, используется и в app.py (handler), и в bot.py (для кнопки) — импорт корректный ✓

Готов к исполнению.
