# Daily Planner — Auto Calendar Time Blocks

> **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:** Dobby automatically creates Google Calendar time blocks for each project based on priority, deadlines, and free slots — then posts a daily briefing to Slack.

**Architecture:** A daemon background loop (`_planner_loop`) runs daily at a configured time. It reads Google Calendar events via the GCal MCP tools (through a dispatched agent), computes optimal time blocks using a built-in algorithm, creates calendar events for each block, and posts a Slack briefing. User preferences (work hours, break frequency, focus block size) are stored in Dobby's existing preference system. A `/planner/*` API provides the dashboard with plan data.

**Tech Stack:** Python asyncio, aiohttp, Google Calendar MCP tools (via agent dispatch), Slack Block Kit, SQLite

---

## File Structure

| File | Action | Responsibility |
|------|--------|---------------|
| `dobby/core/planner.py` | **CREATE** | Time block algorithm, plan generation, preference defaults |
| `dobby/mcp/server.py` | **MODIFY** | Add `dobby_plan_day` and `dobby_planner_prefs` MCP tools |
| `dobby/core/api.py` | **MODIFY** | Add `/planner/today`, `/planner/generate`, `/planner/settings` routes |
| `dobby/core/daemon.py` | **MODIFY** | Add `_planner_loop` background task alongside `_todo_digest_loop` |
| `dobby/models.py` | **MODIFY** | Add `TimeBlock` and `DailyPlan` dataclasses |
| `dobby/store/sqlite.py` | **MODIFY** | Add `daily_plans` table |
| `dobby/slack/notifier.py` | **MODIFY** | Add `post_daily_plan` method |
| `tests/test_planner.py` | **CREATE** | Unit tests for time block algorithm |

---

### Task 1: Data Models

**Files:**
- Modify: `dobby/models.py` (append after Todo dataclass)
- Test: `tests/test_planner.py`

- [ ] **Step 1: Write failing test for TimeBlock creation**

```python
# tests/test_planner.py
"""Tests for the daily planner time-block algorithm."""
from datetime import datetime, timezone
from dobby.models import TimeBlock, DailyPlan


def test_time_block_creation():
    block = TimeBlock(
        start=datetime(2026, 3, 25, 9, 0, tzinfo=timezone.utc),
        end=datetime(2026, 3, 25, 10, 30, tzinfo=timezone.utc),
        block_type="focus",
        title="SPARE - Deep Work",
        project="spare",
        source="planner",
    )
    assert block.duration_minutes == 90
    assert block.block_type == "focus"


def test_daily_plan_creation():
    plan = DailyPlan(
        date="2026-03-25",
        timezone="America/Los_Angeles",
        blocks=[],
        calendar_events=[],
    )
    assert plan.id.startswith("plan-")
    assert plan.date == "2026-03-25"
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd /Users/simonyu/local/local_orby/dobby && python -m pytest tests/test_planner.py -v`
Expected: FAIL with ImportError (TimeBlock not defined)

- [ ] **Step 3: Add TimeBlock and DailyPlan to models.py**

Add after the existing `Todo` dataclass in `dobby/models.py`:

```python
# ---------------------------------------------------------------------------
# Daily Planner
# ---------------------------------------------------------------------------

@dataclass
class TimeBlock:
    """A scheduled time block in the daily plan."""
    start: datetime
    end: datetime
    block_type: str  # "focus", "meeting", "break", "lunch", "buffer"
    title: str
    project: str | None = None
    source: str = "planner"  # "calendar", "planner", "manual"
    color_id: str | None = None  # Google Calendar color ID (1-11)
    calendar_event_id: str | None = None  # Created GCal event ID

    @property
    def duration_minutes(self) -> int:
        return int((self.end - self.start).total_seconds() / 60)

    def to_dict(self) -> dict:
        return {
            "start": self.start.isoformat(),
            "end": self.end.isoformat(),
            "block_type": self.block_type,
            "title": self.title,
            "project": self.project,
            "source": self.source,
            "duration_minutes": self.duration_minutes,
            "color_id": self.color_id,
            "calendar_event_id": self.calendar_event_id,
        }

    @classmethod
    def from_dict(cls, d: dict) -> "TimeBlock":
        return cls(
            start=datetime.fromisoformat(d["start"]),
            end=datetime.fromisoformat(d["end"]),
            block_type=d["block_type"],
            title=d["title"],
            project=d.get("project"),
            source=d.get("source", "planner"),
            color_id=d.get("color_id"),
            calendar_event_id=d.get("calendar_event_id"),
        )


@dataclass
class DailyPlan:
    """A generated daily plan with time blocks."""
    date: str  # YYYY-MM-DD
    timezone: str
    blocks: list[TimeBlock]
    calendar_events: list[dict]  # Raw calendar events that were considered
    id: str = field(default_factory=lambda: f"plan-{secrets.token_hex(4)}")
    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
    briefing_message_ts: str | None = None  # Slack message ts

    def to_dict(self) -> dict:
        return {
            "id": self.id,
            "date": self.date,
            "timezone": self.timezone,
            "blocks": [b.to_dict() for b in self.blocks],
            "calendar_events": self.calendar_events,
            "created_at": self.created_at.isoformat(),
            "briefing_message_ts": self.briefing_message_ts,
        }

    @classmethod
    def from_dict(cls, d: dict) -> "DailyPlan":
        return cls(
            id=d["id"],
            date=d["date"],
            timezone=d["timezone"],
            blocks=[TimeBlock.from_dict(b) for b in d.get("blocks", [])],
            calendar_events=d.get("calendar_events", []),
            created_at=datetime.fromisoformat(d["created_at"]) if d.get("created_at") else datetime.now(timezone.utc),
            briefing_message_ts=d.get("briefing_message_ts"),
        )
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd /Users/simonyu/local/local_orby/dobby && python -m pytest tests/test_planner.py -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add dobby/models.py tests/test_planner.py
git commit -m "feat(planner): add TimeBlock and DailyPlan data models"
```

---

### Task 2: Time Block Generation Algorithm

**Files:**
- Create: `dobby/core/planner.py`
- Test: `tests/test_planner.py` (append)

- [ ] **Step 1: Write failing tests for the block generator**

Append to `tests/test_planner.py`:

```python
from dobby.core.planner import PlannerEngine, PlannerPrefs


def test_planner_prefs_defaults():
    prefs = PlannerPrefs()
    assert prefs.work_start == "09:00"
    assert prefs.work_end == "18:00"
    assert prefs.min_focus_minutes == 60
    assert prefs.break_minutes == 15
    assert prefs.lunch_start == "12:00"
    assert prefs.lunch_minutes == 60


def test_generate_blocks_empty_calendar():
    """With no meetings, should fill day with focus blocks + lunch + breaks."""
    prefs = PlannerPrefs(work_start="09:00", work_end="17:00", min_focus_minutes=90, break_minutes=15, lunch_minutes=60)
    engine = PlannerEngine(prefs)
    projects = [
        {"name": "spare", "priority": "high", "weight": 3},
        {"name": "adpo", "priority": "high", "weight": 2},
        {"name": "dobby", "priority": "normal", "weight": 1},
    ]
    blocks = engine.generate_blocks(
        date="2026-03-25",
        tz_name="America/Los_Angeles",
        calendar_events=[],
        projects=projects,
        todos=[],
    )
    # Should have focus blocks, a lunch block, and breaks
    types = {b.block_type for b in blocks}
    assert "focus" in types
    assert "lunch" in types
    # Total focus time should fill most of the day
    focus_minutes = sum(b.duration_minutes for b in blocks if b.block_type == "focus")
    assert focus_minutes >= 300  # At least 5 hours of focus in an 8-hour day


def test_generate_blocks_with_meetings():
    """Meetings should be preserved and focus blocks fill gaps."""
    prefs = PlannerPrefs(work_start="09:00", work_end="17:00", min_focus_minutes=60)
    engine = PlannerEngine(prefs)
    meetings = [
        {
            "summary": "STLM",
            "start": {"dateTime": "2026-03-25T10:00:00-07:00"},
            "end": {"dateTime": "2026-03-25T11:00:00-07:00"},
        },
        {
            "summary": "1:1 with advisor",
            "start": {"dateTime": "2026-03-25T14:00:00-07:00"},
            "end": {"dateTime": "2026-03-25T15:00:00-07:00"},
        },
    ]
    blocks = engine.generate_blocks(
        date="2026-03-25",
        tz_name="America/Los_Angeles",
        calendar_events=meetings,
        projects=[{"name": "spare", "priority": "high", "weight": 1}],
        todos=[],
    )
    meeting_blocks = [b for b in blocks if b.block_type == "meeting"]
    assert len(meeting_blocks) == 2
    assert meeting_blocks[0].title == "STLM"
    # Focus blocks should not overlap with meetings
    focus_blocks = [b for b in blocks if b.block_type == "focus"]
    for fb in focus_blocks:
        for mb in meeting_blocks:
            assert fb.end <= mb.start or fb.start >= mb.end, f"Focus {fb.title} overlaps meeting {mb.title}"


def test_project_weight_allocation():
    """Higher-weight projects should get more focus time."""
    prefs = PlannerPrefs(work_start="09:00", work_end="17:00")
    engine = PlannerEngine(prefs)
    projects = [
        {"name": "spare", "priority": "high", "weight": 3},
        {"name": "dobby", "priority": "low", "weight": 1},
    ]
    blocks = engine.generate_blocks(
        date="2026-03-25",
        tz_name="America/Los_Angeles",
        calendar_events=[],
        projects=projects,
        todos=[],
    )
    spare_mins = sum(b.duration_minutes for b in blocks if b.project == "spare")
    dobby_mins = sum(b.duration_minutes for b in blocks if b.project == "dobby")
    # spare should get roughly 3x the time
    assert spare_mins > dobby_mins
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd /Users/simonyu/local/local_orby/dobby && python -m pytest tests/test_planner.py -v`
Expected: FAIL with ImportError (PlannerEngine not defined)

- [ ] **Step 3: Implement PlannerEngine**

Create `dobby/core/planner.py`:

```python
"""Daily planner — generates time-blocked schedules from calendar + projects."""
from __future__ import annotations

import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo

from dobby.models import DailyPlan, TimeBlock

logger = logging.getLogger(__name__)

# Google Calendar color IDs for block types
BLOCK_COLORS = {
    "focus": "9",     # Blueberry
    "meeting": "7",   # Peacock
    "break": "2",     # Sage
    "lunch": "6",     # Tangerine
    "buffer": "8",    # Graphite
}


@dataclass
class PlannerPrefs:
    """User preferences for the daily planner."""
    work_start: str = "09:00"         # HH:MM local time
    work_end: str = "18:00"
    min_focus_minutes: int = 60       # Minimum focus block length
    break_minutes: int = 15           # Break duration
    break_every_minutes: int = 120    # Insert break after this much focus
    lunch_start: str = "12:00"
    lunch_minutes: int = 60
    buffer_before_meeting: int = 5    # Minutes of buffer before meetings
    planner_time: str = "07:30"       # When to generate the plan (HH:MM local)
    calendar_id: str = "primary"
    create_events: bool = True        # Actually create GCal events
    post_to_slack: bool = True

    @classmethod
    def from_preferences(cls, prefs: list[dict]) -> "PlannerPrefs":
        """Build from Dobby user preferences (key-value pairs)."""
        kwargs = {}
        mapping = {
            "planner-work-start": "work_start",
            "planner-work-end": "work_end",
            "planner-min-focus": "min_focus_minutes",
            "planner-break-duration": "break_minutes",
            "planner-break-every": "break_every_minutes",
            "planner-lunch-start": "lunch_start",
            "planner-lunch-duration": "lunch_minutes",
            "planner-buffer-before-meeting": "buffer_before_meeting",
            "planner-time": "planner_time",
            "planner-calendar-id": "calendar_id",
            "planner-create-events": "create_events",
            "planner-post-to-slack": "post_to_slack",
        }
        for p in prefs:
            key = p.get("key", "").replace("pref:", "")
            if key in mapping:
                val = p.get("value", "")
                attr = mapping[key]
                # Type coercion
                if attr in ("min_focus_minutes", "break_minutes", "break_every_minutes",
                            "lunch_minutes", "buffer_before_meeting"):
                    kwargs[attr] = int(val)
                elif attr in ("create_events", "post_to_slack"):
                    kwargs[attr] = val.lower() in ("true", "1", "yes")
                else:
                    kwargs[attr] = val
        return cls(**kwargs)


def _parse_time(hhmm: str, date: str, tz: ZoneInfo) -> datetime:
    """Parse 'HH:MM' + 'YYYY-MM-DD' into a timezone-aware datetime."""
    h, m = map(int, hhmm.split(":"))
    return datetime.fromisoformat(f"{date}T{h:02d}:{m:02d}:00").replace(tzinfo=tz)


def _parse_event_time(event: dict, key: str, tz: ZoneInfo) -> datetime:
    """Extract start/end datetime from a Google Calendar event dict."""
    time_obj = event.get(key, {})
    if "dateTime" in time_obj:
        return datetime.fromisoformat(time_obj["dateTime"])
    elif "date" in time_obj:
        # All-day event
        return datetime.fromisoformat(time_obj["date"]).replace(tzinfo=tz)
    raise ValueError(f"Cannot parse {key} from event: {event}")


class PlannerEngine:
    """Generates optimal daily time blocks from calendar events + projects."""

    def __init__(self, prefs: PlannerPrefs | None = None):
        self.prefs = prefs or PlannerPrefs()

    def generate_blocks(
        self,
        date: str,
        tz_name: str,
        calendar_events: list[dict],
        projects: list[dict],
        todos: list[dict],
    ) -> list[TimeBlock]:
        """Generate time blocks for a given day.

        Args:
            date: YYYY-MM-DD string
            tz_name: IANA timezone name (e.g., "America/Los_Angeles")
            calendar_events: List of Google Calendar event dicts
            projects: List of project dicts with name, priority, weight
            todos: List of todo dicts with project, priority, due_date
        """
        tz = ZoneInfo(tz_name)
        work_start = _parse_time(self.prefs.work_start, date, tz)
        work_end = _parse_time(self.prefs.work_end, date, tz)
        blocks: list[TimeBlock] = []

        # 1. Parse calendar events into meeting blocks
        meetings: list[TimeBlock] = []
        for ev in calendar_events:
            try:
                start = _parse_event_time(ev, "start", tz)
                end = _parse_event_time(ev, "end", tz)
                # Skip all-day events and events outside work hours
                if (end - start).total_seconds() >= 86400:
                    continue
                # Clamp to work hours
                start = max(start, work_start)
                end = min(end, work_end)
                if start >= end:
                    continue
                meetings.append(TimeBlock(
                    start=start,
                    end=end,
                    block_type="meeting",
                    title=ev.get("summary", "Meeting"),
                    source="calendar",
                    color_id=BLOCK_COLORS["meeting"],
                ))
            except (ValueError, KeyError) as e:
                logger.warning("Skipping unparseable calendar event: %s", e)

        meetings.sort(key=lambda b: b.start)
        blocks.extend(meetings)

        # 2. Add lunch block
        lunch_start = _parse_time(self.prefs.lunch_start, date, tz)
        lunch_end = lunch_start + timedelta(minutes=self.prefs.lunch_minutes)
        # Don't overlap with meetings — shift lunch if needed
        lunch_start, lunch_end = self._fit_slot(
            lunch_start, lunch_end, meetings, work_start, work_end
        )
        if lunch_start and lunch_end:
            blocks.append(TimeBlock(
                start=lunch_start,
                end=lunch_end,
                block_type="lunch",
                title="Lunch",
                source="planner",
                color_id=BLOCK_COLORS["lunch"],
            ))

        # 3. Find free slots (gaps between fixed blocks)
        blocks.sort(key=lambda b: b.start)
        free_slots = self._find_free_slots(blocks, work_start, work_end)

        # 4. Boost project weights based on todos due today/tomorrow
        project_weights = self._compute_project_weights(projects, todos, date)

        # 5. Allocate focus blocks to free slots using weighted round-robin
        focus_blocks = self._allocate_focus_blocks(free_slots, project_weights, tz)
        blocks.extend(focus_blocks)

        blocks.sort(key=lambda b: b.start)
        return blocks

    def _fit_slot(
        self,
        start: datetime,
        end: datetime,
        fixed: list[TimeBlock],
        work_start: datetime,
        work_end: datetime,
    ) -> tuple[datetime | None, datetime | None]:
        """Try to fit a slot without overlapping fixed blocks. Shift if needed."""
        duration = end - start
        # Check if original slot works
        if not any(start < f.end and end > f.start for f in fixed):
            return start, end
        # Try shifting after each fixed block
        for f in fixed:
            candidate_start = f.end
            candidate_end = candidate_start + duration
            if candidate_end <= work_end:
                if not any(candidate_start < g.end and candidate_end > g.start for g in fixed):
                    return candidate_start, candidate_end
        return None, None

    def _find_free_slots(
        self,
        fixed_blocks: list[TimeBlock],
        work_start: datetime,
        work_end: datetime,
    ) -> list[tuple[datetime, datetime]]:
        """Find gaps between fixed blocks within work hours."""
        slots = []
        cursor = work_start
        for block in fixed_blocks:
            if block.start > cursor:
                gap = (block.start - cursor).total_seconds() / 60
                if gap >= self.prefs.min_focus_minutes:
                    slots.append((cursor, block.start))
            cursor = max(cursor, block.end)
        # Remaining time after last block
        if cursor < work_end:
            gap = (work_end - cursor).total_seconds() / 60
            if gap >= self.prefs.min_focus_minutes:
                slots.append((cursor, work_end))
        return slots

    def _compute_project_weights(
        self,
        projects: list[dict],
        todos: list[dict],
        date: str,
    ) -> list[dict]:
        """Compute effective weights, boosting projects with due todos."""
        weights = []
        for p in projects:
            w = p.get("weight", 1)
            # Boost by 50% for each todo due today or overdue
            for t in todos:
                if t.get("project") == p["name"]:
                    due = t.get("due_date", "")
                    if due and due[:10] <= date:
                        w += 1
            weights.append({**p, "effective_weight": w})
        return weights

    def _allocate_focus_blocks(
        self,
        free_slots: list[tuple[datetime, datetime]],
        project_weights: list[dict],
        tz: ZoneInfo,
    ) -> list[TimeBlock]:
        """Allocate focus blocks to free slots using weighted distribution."""
        if not project_weights or not free_slots:
            return []

        total_weight = sum(p["effective_weight"] for p in project_weights)
        if total_weight == 0:
            return []

        # Calculate total available focus minutes
        total_minutes = sum(
            (end - start).total_seconds() / 60 for start, end in free_slots
        )

        # Assign minutes per project proportionally
        project_minutes = []
        for p in project_weights:
            frac = p["effective_weight"] / total_weight
            mins = total_minutes * frac
            project_minutes.append({"project": p, "remaining": mins})

        # Sort projects by weight descending (highest priority gets first pick)
        project_minutes.sort(key=lambda x: -x["project"]["effective_weight"])

        blocks: list[TimeBlock] = []
        break_interval = self.prefs.break_every_minutes
        focus_since_break = 0

        for slot_start, slot_end in free_slots:
            cursor = slot_start

            for pm in project_minutes:
                if pm["remaining"] <= 0 or cursor >= slot_end:
                    continue

                # Insert break if we've been focusing too long
                if focus_since_break >= break_interval and cursor + timedelta(minutes=self.prefs.break_minutes) < slot_end:
                    break_end = cursor + timedelta(minutes=self.prefs.break_minutes)
                    blocks.append(TimeBlock(
                        start=cursor,
                        end=break_end,
                        block_type="break",
                        title="Break",
                        source="planner",
                        color_id=BLOCK_COLORS["break"],
                    ))
                    cursor = break_end
                    focus_since_break = 0

                # Allocate focus time
                available = (slot_end - cursor).total_seconds() / 60
                alloc = min(pm["remaining"], available)
                if alloc < self.prefs.min_focus_minutes:
                    continue

                block_end = cursor + timedelta(minutes=alloc)
                p = pm["project"]
                blocks.append(TimeBlock(
                    start=cursor,
                    end=block_end,
                    block_type="focus",
                    title=f"{p['name'].upper()} — Deep Work",
                    project=p["name"],
                    source="planner",
                    color_id=BLOCK_COLORS["focus"],
                ))
                pm["remaining"] -= alloc
                focus_since_break += alloc
                cursor = block_end

        return blocks

    def generate_plan(
        self,
        date: str,
        tz_name: str,
        calendar_events: list[dict],
        projects: list[dict],
        todos: list[dict],
    ) -> DailyPlan:
        """Generate a full DailyPlan with blocks."""
        blocks = self.generate_blocks(date, tz_name, calendar_events, projects, todos)
        return DailyPlan(
            date=date,
            timezone=tz_name,
            blocks=blocks,
            calendar_events=calendar_events,
        )
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd /Users/simonyu/local/local_orby/dobby && python -m pytest tests/test_planner.py -v`
Expected: ALL PASS

- [ ] **Step 5: Commit**

```bash
git add dobby/core/planner.py tests/test_planner.py
git commit -m "feat(planner): time block generation algorithm with weighted project allocation"
```

---

### Task 3: Storage Layer — daily_plans Table

**Files:**
- Modify: `dobby/store/sqlite.py`

- [ ] **Step 1: Find the initialize method in sqlite.py and add the new table**

Add to `SqliteStore.initialize()`, after the existing `CREATE TABLE` statements:

```python
await db.execute("""
    CREATE TABLE IF NOT EXISTS daily_plans (
        id TEXT PRIMARY KEY,
        data TEXT NOT NULL
    )
""")
```

- [ ] **Step 2: Add CRUD methods for daily plans**

Add these methods to the `SqliteStore` class:

```python
async def store_daily_plan(self, plan: "DailyPlan") -> None:
    """Persist a daily plan."""
    import json
    async with self._connect() as db:
        await db.execute(
            "INSERT OR REPLACE INTO daily_plans (id, data) VALUES (?, ?)",
            (plan.id, json.dumps(plan.to_dict())),
        )
        await db.commit()

async def get_daily_plan(self, plan_id: str) -> "DailyPlan | None":
    """Retrieve a daily plan by ID."""
    import json
    from dobby.models import DailyPlan
    async with self._connect() as db:
        row = await db.execute(
            "SELECT data FROM daily_plans WHERE id = ?", (plan_id,)
        )
        row = await row.fetchone()
        if row:
            return DailyPlan.from_dict(json.loads(row[0]))
    return None

async def get_plan_for_date(self, date: str) -> "DailyPlan | None":
    """Retrieve the most recent plan for a given date (YYYY-MM-DD)."""
    import json
    from dobby.models import DailyPlan
    async with self._connect() as db:
        row = await db.execute(
            "SELECT data FROM daily_plans WHERE json_extract(data, '$.date') = ? ORDER BY json_extract(data, '$.created_at') DESC LIMIT 1",
            (date,),
        )
        row = await row.fetchone()
        if row:
            return DailyPlan.from_dict(json.loads(row[0]))
    return None
```

- [ ] **Step 3: Run existing tests to make sure nothing broke**

Run: `cd /Users/simonyu/local/local_orby/dobby && python -m pytest tests/ -v --tb=short 2>&1 | head -40`
Expected: PASS (no regressions)

- [ ] **Step 4: Commit**

```bash
git add dobby/store/sqlite.py
git commit -m "feat(planner): add daily_plans table to SQLite store"
```

---

### Task 4: API Endpoints

**Files:**
- Modify: `dobby/core/api.py`

- [ ] **Step 1: Add planner routes**

Add these route handlers to `api.py`. Find the section where other routes are registered (e.g., near the `/schedule` route) and add:

```python
# --- Planner routes ---

async def handle_planner_today(request: web.Request) -> web.Response:
    """GET /planner/today — Get today's plan."""
    store = request.app["store"]
    tz_name = request.query.get("timezone", "America/Los_Angeles")
    from zoneinfo import ZoneInfo
    from datetime import datetime
    tz = ZoneInfo(tz_name)
    today = datetime.now(tz).strftime("%Y-%m-%d")
    plan = await store.get_plan_for_date(today)
    if plan is None:
        return web.json_response({"plan": None, "date": today})
    return web.json_response({"plan": plan.to_dict(), "date": today})


async def handle_planner_generate(request: web.Request) -> web.Response:
    """POST /planner/generate — Generate a new daily plan.

    Expects JSON body:
    {
        "date": "2026-03-25",         (optional, defaults to today)
        "timezone": "America/Los_Angeles",
        "calendar_events": [...],      (Google Calendar events)
        "projects": [{"name": "spare", "weight": 3}, ...],
        "todos": [...]
    }
    """
    body = await request.json()
    store = request.app["store"]

    from zoneinfo import ZoneInfo
    from datetime import datetime
    from dobby.core.planner import PlannerEngine, PlannerPrefs

    tz_name = body.get("timezone", "America/Los_Angeles")
    tz = ZoneInfo(tz_name)
    date = body.get("date", datetime.now(tz).strftime("%Y-%m-%d"))

    # Load user preferences
    prefs_data = await store.search_memories(type="preference")
    planner_prefs = PlannerPrefs.from_preferences(prefs_data) if prefs_data else PlannerPrefs()

    engine = PlannerEngine(planner_prefs)
    plan = engine.generate_plan(
        date=date,
        tz_name=tz_name,
        calendar_events=body.get("calendar_events", []),
        projects=body.get("projects", []),
        todos=body.get("todos", []),
    )

    await store.store_daily_plan(plan)
    return web.json_response(plan.to_dict(), status=201)


async def handle_planner_settings(request: web.Request) -> web.Response:
    """GET /planner/settings — Get current planner preferences."""
    store = request.app["store"]
    prefs_data = await store.search_memories(type="preference")
    planner_prefs = []
    for p in (prefs_data or []):
        key = p.get("key", "")
        if key.startswith("pref:planner-"):
            planner_prefs.append(p)

    from dobby.core.planner import PlannerPrefs
    prefs = PlannerPrefs.from_preferences(planner_prefs) if planner_prefs else PlannerPrefs()
    return web.json_response({
        "preferences": {
            "work_start": prefs.work_start,
            "work_end": prefs.work_end,
            "min_focus_minutes": prefs.min_focus_minutes,
            "break_minutes": prefs.break_minutes,
            "break_every_minutes": prefs.break_every_minutes,
            "lunch_start": prefs.lunch_start,
            "lunch_minutes": prefs.lunch_minutes,
            "buffer_before_meeting": prefs.buffer_before_meeting,
            "planner_time": prefs.planner_time,
            "calendar_id": prefs.calendar_id,
            "create_events": prefs.create_events,
            "post_to_slack": prefs.post_to_slack,
        },
        "raw": planner_prefs,
    })
```

- [ ] **Step 2: Register the routes**

In the `create_app` function, add route registrations:

```python
app.router.add_get("/planner/today", handle_planner_today)
app.router.add_post("/planner/generate", handle_planner_generate)
app.router.add_get("/planner/settings", handle_planner_settings)
```

- [ ] **Step 3: Commit**

```bash
git add dobby/core/api.py
git commit -m "feat(planner): add /planner/today, /planner/generate, /planner/settings API endpoints"
```

---

### Task 5: MCP Tools

**Files:**
- Modify: `dobby/mcp/server.py`

- [ ] **Step 1: Add dobby_plan_day MCP tool**

Add before the `main()` function at the end of `server.py`:

```python
@server.tool()
async def dobby_plan_day(
    timezone: str = "America/Los_Angeles",
    date: Optional[str] = None,
    calendar_events: Optional[str] = None,
    project_weights: Optional[str] = None,
) -> str:
    """Generate a daily plan with time blocks for your projects.

    Call this after reading your Google Calendar events for the day.
    Pass the calendar events as a JSON string and Dobby will create
    optimized focus blocks, breaks, and lunch around your meetings.

    Workflow:
    1. Use google-calendar MCP to read today's events
    2. Call this tool with those events
    3. Use google-calendar MCP to create the returned time blocks as events

    Args:
        timezone: Your IANA timezone (e.g., "America/Los_Angeles")
        date: Date to plan (YYYY-MM-DD, defaults to today)
        calendar_events: JSON string of calendar events from Google Calendar
        project_weights: JSON string of project weights, e.g.,
            [{"name": "spare", "weight": 3}, {"name": "adpo", "weight": 2}]
            If omitted, uses all registered projects with equal weight.
    """
    client = get_client()

    # Parse inputs
    import json as _json
    from datetime import datetime as _dt
    from zoneinfo import ZoneInfo as _ZI

    tz = _ZI(timezone)
    plan_date = date or _dt.now(tz).strftime("%Y-%m-%d")

    events = _json.loads(calendar_events) if calendar_events else []

    if project_weights:
        projects = _json.loads(project_weights)
    else:
        # Fetch registered projects and assign equal weight
        all_projects = await client.get("/projects")
        projects = [{"name": p["name"], "weight": 1, "priority": "normal"} for p in all_projects]

    # Fetch open todos for deadline-aware scheduling
    todos_raw = await client.get("/todos", params={"status": "open"})
    todos = todos_raw if todos_raw else []

    # Generate the plan via the API
    body = {
        "date": plan_date,
        "timezone": timezone,
        "calendar_events": events,
        "projects": projects,
        "todos": todos,
    }
    plan = await client.post("/planner/generate", json=body)

    # Format response with actionable instructions
    blocks = plan.get("blocks", [])
    lines = [f"📋 Daily Plan for {plan_date}\n"]
    for b in blocks:
        emoji = {"focus": "🎯", "meeting": "📅", "break": "☕", "lunch": "🍽️", "buffer": "⏳"}.get(b["block_type"], "📌")
        start_str = b["start"].split("T")[1][:5] if "T" in b["start"] else b["start"]
        end_str = b["end"].split("T")[1][:5] if "T" in b["end"] else b["end"]
        proj = f" [{b['project']}]" if b.get("project") else ""
        lines.append(f"{emoji} {start_str}-{end_str} {b['title']}{proj} ({b['duration_minutes']}min)")

    lines.append(f"\nTotal blocks: {len(blocks)}")
    focus_mins = sum(b["duration_minutes"] for b in blocks if b["block_type"] == "focus")
    lines.append(f"Focus time: {focus_mins // 60}h {focus_mins % 60}m")

    # Include instructions for the agent to create calendar events
    lines.append("\n--- CREATE THESE AS CALENDAR EVENTS ---")
    lines.append("Use google-calendar MCP tool (gcal_create_event) to create each focus/break/lunch block.")
    lines.append("Use sendUpdates='none' to avoid notification spam.")
    for b in blocks:
        if b["source"] == "planner":  # Only create planner-generated blocks
            color = b.get("color_id", "9")
            lines.append(
                f"  gcal_create_event: summary=\"{b['title']}\", "
                f"start={{dateTime: \"{b['start']}\"}}, "
                f"end={{dateTime: \"{b['end']}\"}}, "
                f"colorId=\"{color}\""
            )

    return "\n".join(lines)
```

- [ ] **Step 2: Commit**

```bash
git add dobby/mcp/server.py
git commit -m "feat(planner): add dobby_plan_day MCP tool for daily time block generation"
```

---

### Task 6: Slack Briefing

**Files:**
- Modify: `dobby/slack/notifier.py`

- [ ] **Step 1: Add post_daily_plan method to SlackNotifier**

```python
async def post_daily_plan(self, plan: dict) -> str | None:
    """Post a formatted daily plan briefing to Slack. Returns message_ts."""
    blocks_data = plan.get("blocks", [])
    date = plan.get("date", "today")

    # Build timeline
    lines = [f":calendar: *Daily Plan — {date}*\n"]

    for b in blocks_data:
        emoji = {
            "focus": ":dart:",
            "meeting": ":spiral_calendar_pad:",
            "break": ":coffee:",
            "lunch": ":fork_and_knife:",
            "buffer": ":hourglass:",
        }.get(b["block_type"], ":pushpin:")

        start_str = b["start"].split("T")[1][:5] if "T" in b["start"] else ""
        end_str = b["end"].split("T")[1][:5] if "T" in b["end"] else ""
        proj = f" `{b['project']}`" if b.get("project") else ""
        lines.append(f"{emoji} *{start_str}–{end_str}* {b['title']}{proj}")

    # Summary stats
    focus_mins = sum(b["duration_minutes"] for b in blocks_data if b["block_type"] == "focus")
    meeting_mins = sum(b["duration_minutes"] for b in blocks_data if b["block_type"] == "meeting")
    lines.append(f"\n:chart_with_upwards_trend: Focus: *{focus_mins // 60}h {focus_mins % 60}m* | Meetings: *{meeting_mins // 60}h {meeting_mins % 60}m*")

    text = "\n".join(lines)
    slack_blocks = [
        {"type": "section", "text": {"type": "mrkdwn", "text": text}},
    ]

    try:
        result = await self._post(blocks=slack_blocks, channel=self._report_channel)
        return result.get("ts") if result else None
    except Exception as exc:
        logger.warning("Failed to post daily plan: %s", exc)
        return None
```

- [ ] **Step 2: Commit**

```bash
git add dobby/slack/notifier.py
git commit -m "feat(planner): add Slack daily plan briefing formatter"
```

---

### Task 7: Daemon Planner Loop

**Files:**
- Modify: `dobby/core/daemon.py`

- [ ] **Step 1: Add _planner_loop method**

Add this method alongside `_todo_digest_loop`:

```python
async def _planner_loop(self, stop_event: asyncio.Event) -> None:
    """Background loop that generates daily plans and dispatches calendar creation.

    Runs a planning agent at the configured planner time (default 07:30 local).
    The agent reads Google Calendar, generates time blocks, creates GCal events,
    and posts a Slack briefing.
    """
    last_plan_date: str | None = None
    while not stop_event.is_set():
        try:
            await asyncio.sleep(300)  # Check every 5 minutes
            if self.store is None:
                continue

            # Load planner preferences
            prefs_data = await self.store.search_memories(type="preference")
            planner_time = "07:30"
            planner_tz = "America/Los_Angeles"
            planner_enabled = False

            for p in (prefs_data or []):
                key = p.get("key", "")
                if key == "pref:planner-time":
                    planner_time = p.get("value", "07:30")
                elif key == "pref:planner-timezone":
                    planner_tz = p.get("value", "America/Los_Angeles")
                elif key == "pref:planner-enabled":
                    planner_enabled = p.get("value", "").lower() in ("true", "1", "yes")

            if not planner_enabled:
                continue

            from zoneinfo import ZoneInfo
            tz = ZoneInfo(planner_tz)
            now_local = datetime.now(tz)
            today_str = now_local.strftime("%Y-%m-%d")

            # Already planned today?
            if last_plan_date == today_str:
                continue

            # Check if it's time
            plan_h, plan_m = map(int, planner_time.split(":"))
            if now_local.hour < plan_h or (now_local.hour == plan_h and now_local.minute < plan_m):
                continue

            last_plan_date = today_str
            logger.info("Planner: generating daily plan for %s", today_str)

            # Dispatch a planning agent task
            from dobby.models import Task, Schedule, ScheduleType, Priority
            planner_task = Task(
                project="dobby",
                description=self._build_planner_prompt(today_str, planner_tz),
                exit_criteria="Calendar events created and Slack briefing posted",
                priority=Priority.HIGH,
                schedule=Schedule(type=ScheduleType.ONCE),
            )
            await self.store.store_task(planner_task)
            logger.info("Planner: queued planning task %s", planner_task.id)

        except asyncio.CancelledError:
            return
        except Exception as exc:
            logger.warning("Planner loop error: %s", exc)

def _build_planner_prompt(self, date: str, tz_name: str) -> str:
    """Build the prompt for the daily planning agent."""
    return f"""You are Dobby's daily planner agent. Generate today's schedule.

## Steps

1. **Read calendar**: Use the google-calendar MCP tool to list today's events:
   - Call gcal_list_events with timeMin="{date}T00:00:00" timeMax="{date}T23:59:59" timeZone="{tz_name}"

2. **Generate plan**: Call the dobby_plan_day tool with:
   - timezone="{tz_name}"
   - date="{date}"
   - calendar_events= (paste the JSON events from step 1)
   - The tool will return time blocks with instructions

3. **Create calendar events**: For EACH focus/break/lunch block returned:
   - Call gcal_create_event with the exact parameters shown
   - Use sendUpdates="none" to avoid notifications
   - Add description "[Dobby Planner] Auto-generated focus block"

4. **Post Slack briefing**: Use the slack MCP tool to post the plan summary
   to the user's preferred channel

5. **Report results**: Include the plan summary and created event count in your result.
"""
```

- [ ] **Step 2: Start the planner loop in `run()`**

In the `run()` method, right after the `todo_digest_task` creation (line ~349-351), add:

```python
planner_task = asyncio.create_task(
    self._planner_loop(stop_event), name="planner"
)
```

And in the `finally:` block, add:

```python
planner_task.cancel()
```

- [ ] **Step 3: Commit**

```bash
git add dobby/core/daemon.py
git commit -m "feat(planner): add daemon planner loop that dispatches daily planning agent"
```

---

### Task 8: Enable Planner via Preferences

This task sets up the planner for the user by configuring preferences.

- [ ] **Step 1: Set initial planner preferences via Dobby MCP**

Use the `dobby_user_prefs` tool to set these preferences:

```
planner-enabled = true
planner-time = 07:30
planner-timezone = America/Los_Angeles
planner-work-start = 09:00
planner-work-end = 18:00
planner-min-focus = 60
planner-break-duration = 15
planner-break-every = 120
planner-lunch-start = 12:00
planner-lunch-duration = 60
planner-create-events = true
planner-post-to-slack = true
```

- [ ] **Step 2: Verify by calling GET /planner/settings**

Run: `curl http://127.0.0.1:8420/planner/settings | python -m json.tool`
Expected: Should show all preferences with correct values

- [ ] **Step 3: Test plan generation manually**

Run: `curl -X POST http://127.0.0.1:8420/planner/generate -H 'Content-Type: application/json' -d '{"date": "2026-03-25", "timezone": "America/Los_Angeles", "calendar_events": [], "projects": [{"name": "spare", "weight": 3}, {"name": "adpo", "weight": 2}], "todos": []}' | python -m json.tool`
Expected: Should return a plan with focus blocks allocated to spare (3/5) and adpo (2/5)

- [ ] **Step 4: Commit any preference changes**

```bash
git add -A
git commit -m "feat(planner): configure default planner preferences"
```

---

## Execution Notes

- **Tasks 1-2** are the core — they define the data models and algorithm
- **Tasks 3-4** wire it into the server
- **Task 5** makes it available to agents via MCP
- **Task 6** handles Slack formatting
- **Task 7** makes it autonomous (the daemon loop)
- **Task 8** is configuration/activation

The planner agent dispatched by the daemon loop uses these MCP tools (already available):
- `google-calendar` — reads events + creates time blocks
- `slack` — posts the daily briefing
- `dobby` — calls `dobby_plan_day` for the algorithm

No new external dependencies are needed.
