# Internal Calendar with CalDAV Sync — Implementation Plan > **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:** Revive the dormant `events` DB table as the source of truth for calendar events, add CRUD REST API, rewire AI tools to write through internal DB, and build a FullCalendar month/week UI with a slide-over form — with best-effort push sync to CalDAV. **Architecture:** Fable DB is source of truth; CalDAV push is fire-and-forget background tasks after each write. AI tools are always available (no `is_caldav_configured` gate). Two new columns (`caldav_uid`, `color`) are added to the existing `events` table via migration 0029. **Tech Stack:** Python/SQLAlchemy (service + routes), Quart (blueprint), FullCalendar Vue 3 (`@fullcalendar/vue3`, `daygrid`, `timegrid`, `interaction`), Vue 3 SFC. **Spec:** `docs/specs/2026-03-25-internal-calendar-design.md` --- ## File Map | File | Action | Purpose | |------|--------|---------| | `alembic/versions/0029_add_calendar_columns.py` | Create | Migration: add `caldav_uid`, `color` to events table | | `src/fabledassistant/models/event.py` | Modify | Add `caldav_uid`, `color` mapped columns; update `to_dict()` | | `src/fabledassistant/services/caldav.py` | Modify | Add optional `uid` param to `create_event` for UID injection | | `src/fabledassistant/services/events.py` | Create | CRUD service + CalDAV push coordination | | `src/fabledassistant/routes/events.py` | Create | REST blueprint: GET/POST /api/events, GET/PATCH/DELETE /api/events/:id | | `src/fabledassistant/app.py` | Modify | Register events blueprint | | `src/fabledassistant/services/tools.py` | Modify | Rewire calendar tools to events.py; move to _CORE_TOOLS; remove gate | | `frontend/src/api/client.ts` | Modify | Add `EventEntry` interface + 5 typed helpers | | `frontend/package.json` | Modify | Add FullCalendar dependencies | | `frontend/src/components/EventSlideOver.vue` | Create | Slide-over form for create/edit/delete | | `frontend/src/views/CalendarView.vue` | Create | Month/week FullCalendar view | | `frontend/src/router/index.ts` | Modify | Add `/calendar` route | | `frontend/src/components/AppHeader.vue` | Modify | Add Calendar nav link | | `frontend/src/App.vue` | Modify | Add `g`+`l` keyboard shortcut | | `tests/test_events_service.py` | Create | Service unit tests (mocked DB) | | `tests/test_events_routes.py` | Create | Route unit tests | --- ## Task 1: Migration 0029 — add caldav_uid and color columns **Files:** - Create: `alembic/versions/0029_add_calendar_columns.py` - [ ] **Step 1: Write the migration** ```python """Add caldav_uid and color columns to events table.""" from alembic import op revision = "0029" down_revision = "0028" def upgrade() -> None: op.execute(""" ALTER TABLE events ADD COLUMN IF NOT EXISTS caldav_uid TEXT DEFAULT '', ADD COLUMN IF NOT EXISTS color TEXT DEFAULT '' """) def downgrade() -> None: op.execute("ALTER TABLE events DROP COLUMN IF EXISTS color") op.execute("ALTER TABLE events DROP COLUMN IF EXISTS caldav_uid") ``` - [ ] **Step 2: Commit** ```bash git add alembic/versions/0029_add_calendar_columns.py git commit -m "feat(calendar): migration 0029 — add caldav_uid and color to events" ``` --- ## Task 2: Update Event model **Files:** - Modify: `src/fabledassistant/models/event.py` - [ ] **Step 1: Write the failing test** Create `tests/test_events_model.py`: ```python def test_event_model_has_new_columns(): from fabledassistant.models.event import Event cols = {c.key for c in Event.__table__.columns} assert "caldav_uid" in cols assert "color" in cols def test_event_to_dict_includes_new_fields(): from fabledassistant.models.event import Event from datetime import datetime, timezone e = Event( user_id=1, uid="test-uid", title="Test", start_dt=datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc), caldav_uid="sync-uid", color="#6366f1", ) d = e.to_dict() assert d["caldav_uid"] == "sync-uid" assert d["color"] == "#6366f1" ``` - [ ] **Step 2: Run test to verify it fails** ```bash docker compose exec app python -m pytest tests/test_events_model.py -v ``` Expected: FAIL with `AssertionError` (columns not yet present) - [ ] **Step 3: Add the two columns to the Event class** In `src/fabledassistant/models/event.py`, after the `location` column (line 27), add: ```python caldav_uid: Mapped[str] = mapped_column(Text, default="") color: Mapped[str] = mapped_column(Text, default="") ``` Update `to_dict()` to include both new fields (add after `"location": self.location,`): ```python "caldav_uid": self.caldav_uid, "color": self.color, ``` Also add `user_id` to `to_dict()` since routes will need it for ownership checks. After `"id": self.id,` add: ```python "user_id": self.user_id, ``` - [ ] **Step 4: Run test to verify it passes** ```bash docker compose exec app python -m pytest tests/test_events_model.py -v ``` Expected: PASS - [ ] **Step 5: Commit** ```bash git add src/fabledassistant/models/event.py tests/test_events_model.py git commit -m "feat(calendar): add caldav_uid, color to Event model" ``` --- ## Task 3: Patch caldav.py — optional uid parameter for create_event **Files:** - Modify: `src/fabledassistant/services/caldav.py:165-257` - [ ] **Step 1: Write the failing test** Add to `tests/test_events_model.py`: ```python def test_caldav_create_event_accepts_uid_param(): """caldav.create_event signature must accept an optional uid param.""" import inspect from fabledassistant.services.caldav import create_event sig = inspect.signature(create_event) assert "uid" in sig.parameters ``` - [ ] **Step 2: Run test to verify it fails** ```bash docker compose exec app python -m pytest tests/test_events_model.py::test_caldav_create_event_accepts_uid_param -v ``` Expected: FAIL - [ ] **Step 3: Add optional uid parameter to caldav.create_event** In `src/fabledassistant/services/caldav.py`, modify the `create_event` function signature (line 165) to add `uid: str | None = None` as the last positional parameter (before `**kwargs` if any, otherwise just append): ```python async def create_event( user_id: int, title: str, start: str, end: str | None = None, duration: int | None = None, description: str | None = None, location: str | None = None, all_day: bool = False, recurrence: str | None = None, timezone: str | None = None, reminder_minutes: int | None = None, attendees: list[str] | None = None, calendar_name: str | None = None, uid: str | None = None, ) -> dict: ``` Then, after the line `event = icalendar.Event()` (around line 195), add before `event.add("summary", title)`: ```python if uid: # Remove auto-generated UID if the library added one, then inject ours if "UID" in event: del event["UID"] event.add("uid", uid) ``` - [ ] **Step 4: Run test to verify it passes** ```bash docker compose exec app python -m pytest tests/test_events_model.py -v ``` Expected: PASS (both tests) - [ ] **Step 5: Commit** ```bash git add src/fabledassistant/services/caldav.py tests/test_events_model.py git commit -m "feat(calendar): add optional uid param to caldav.create_event" ``` --- ## Task 4: Create services/events.py — CRUD + CalDAV push **Files:** - Create: `src/fabledassistant/services/events.py` - Test: `tests/test_events_service.py` - [ ] **Step 1: Write the failing tests** Create `tests/test_events_service.py`: ```python import pytest from unittest.mock import AsyncMock, MagicMock, patch from datetime import datetime, timezone def _make_mock_session(): mock_session = AsyncMock() mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) mock_session.add = MagicMock() mock_session.commit = AsyncMock() mock_session.refresh = AsyncMock() return mock_session def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting", caldav_uid="", color=""): e = MagicMock() e.id = id e.user_id = user_id e.uid = uid e.title = title e.caldav_uid = caldav_uid e.color = color e.start_dt = datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc) e.end_dt = datetime(2026, 3, 25, 11, 0, tzinfo=timezone.utc) e.all_day = False e.description = "" e.location = "" e.recurrence = None e.project_id = None e.to_dict.return_value = { "id": id, "uid": uid, "title": title, "caldav_uid": caldav_uid, "color": color, } return e @pytest.mark.asyncio async def test_create_event_stores_to_db(): mock_session = _make_mock_session() with patch("fabledassistant.services.events.async_session") as mock_cls, \ patch("fabledassistant.services.events.asyncio.create_task") as mock_task: mock_cls.return_value = mock_session from fabledassistant.services.events import create_event result = await create_event( user_id=1, title="Dentist", start_dt=datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc), ) assert mock_session.add.called assert mock_session.commit.called # CalDAV push background task should be scheduled assert mock_task.called @pytest.mark.asyncio async def test_find_events_by_query_returns_ilike_results(): mock_event = _make_mock_event(title="Team Meeting") mock_session = _make_mock_session() mock_result = MagicMock() mock_result.scalars.return_value.all.return_value = [mock_event] mock_session.execute = AsyncMock(return_value=mock_result) with patch("fabledassistant.services.events.async_session") as mock_cls: mock_cls.return_value = mock_session from fabledassistant.services.events import find_events_by_query results = await find_events_by_query(user_id=1, query="meeting") assert len(results) == 1 assert results[0].title == "Team Meeting" @pytest.mark.asyncio async def test_list_events_returns_events_in_range(): mock_event = _make_mock_event() mock_session = _make_mock_session() mock_result = MagicMock() mock_result.scalars.return_value.all.return_value = [mock_event] mock_session.execute = AsyncMock(return_value=mock_result) with patch("fabledassistant.services.events.async_session") as mock_cls: mock_cls.return_value = mock_session from fabledassistant.services.events import list_events results = await list_events( user_id=1, date_from=datetime(2026, 3, 1, tzinfo=timezone.utc), date_to=datetime(2026, 3, 31, tzinfo=timezone.utc), ) assert len(results) == 1 @pytest.mark.asyncio async def test_delete_event_fires_caldav_push_when_uid_set(): mock_event = _make_mock_event(caldav_uid="sync-uid") mock_session = _make_mock_session() mock_result = MagicMock() # get_event query mock_result.scalar_one_or_none.return_value = mock_event mock_session.execute = AsyncMock(return_value=mock_result) with patch("fabledassistant.services.events.async_session") as mock_cls, \ patch("fabledassistant.services.events.asyncio.create_task") as mock_task: mock_cls.return_value = mock_session from fabledassistant.services.events import delete_event await delete_event(user_id=1, event_id=1) # Push task fired because caldav_uid is set assert mock_task.called @pytest.mark.asyncio async def test_update_event_fires_caldav_push(): mock_event = _make_mock_event(caldav_uid="sync-uid") mock_session = _make_mock_session() mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = mock_event mock_session.execute = AsyncMock(return_value=mock_result) with patch("fabledassistant.services.events.async_session") as mock_cls, \ patch("fabledassistant.services.events.asyncio.create_task") as mock_task: mock_cls.return_value = mock_session from fabledassistant.services.events import update_event await update_event(user_id=1, event_id=1, title="Updated Title") assert mock_task.called ``` - [ ] **Step 2: Run test to verify it fails** ```bash docker compose exec app python -m pytest tests/test_events_service.py -v ``` Expected: FAIL with `ModuleNotFoundError` (service doesn't exist yet) - [ ] **Step 3: Implement services/events.py** Create `src/fabledassistant/services/events.py`: ```python """Internal event store service with CalDAV push sync.""" from __future__ import annotations import asyncio import logging import uuid from datetime import datetime, timedelta, timezone from sqlalchemy import and_, or_, select from fabledassistant.models import async_session from fabledassistant.models.event import Event logger = logging.getLogger(__name__) async def create_event( user_id: int, title: str, start_dt: datetime, end_dt: datetime | None = None, all_day: bool = False, description: str = "", location: str = "", color: str = "", recurrence: str | None = None, project_id: int | None = None, # CalDAV-only fields (not stored in DB, forwarded to push) duration: int | None = None, reminder_minutes: int | None = None, attendees: list[str] | None = None, calendar_name: str | None = None, ) -> Event: """Create an event in the DB, then fire a CalDAV push task.""" uid = str(uuid.uuid4()) async with async_session() as session: event = Event( user_id=user_id, uid=uid, title=title, start_dt=start_dt, end_dt=end_dt, all_day=all_day, description=description, location=location, color=color, recurrence=recurrence, project_id=project_id, ) session.add(event) await session.commit() await session.refresh(event) extra_fields = { "duration": duration, "reminder_minutes": reminder_minutes, "attendees": attendees, "calendar_name": calendar_name, } asyncio.create_task(_push_create(event, user_id, extra_fields)) return event async def get_event(user_id: int, event_id: int) -> Event | None: """Return event owned by user_id, or None.""" async with async_session() as session: result = await session.execute( select(Event).where(Event.id == event_id, Event.user_id == user_id) ) return result.scalar_one_or_none() async def list_events( user_id: int, date_from: datetime, date_to: datetime, ) -> list[Event]: """List events for user_id within [date_from, date_to].""" async with async_session() as session: result = await session.execute( select(Event).where( Event.user_id == user_id, Event.start_dt >= date_from, Event.start_dt <= date_to, ).order_by(Event.start_dt) ) return result.scalars().all() async def search_events( user_id: int, query: str, days_ahead: int = 90, ) -> list[Event]: """Search events by keyword in title, description, or location.""" now = datetime.now(timezone.utc) date_to = now + timedelta(days=days_ahead) q = f"%{query}%" async with async_session() as session: result = await session.execute( select(Event).where( Event.user_id == user_id, Event.start_dt >= now, Event.start_dt <= date_to, or_( Event.title.ilike(q), Event.description.ilike(q), Event.location.ilike(q), ), ).order_by(Event.start_dt) ) return result.scalars().all() async def update_event(user_id: int, event_id: int, **fields) -> Event | None: """Partial update. Returns updated event or None if not found.""" async with async_session() as session: result = await session.execute( select(Event).where(Event.id == event_id, Event.user_id == user_id) ) event = result.scalar_one_or_none() if event is None: return None old_title = event.title # capture before mutation for CalDAV lookup allowed = {"title", "start_dt", "end_dt", "all_day", "description", "location", "color", "recurrence", "project_id"} for key, value in fields.items(): if key in allowed and value is not None: setattr(event, key, value) await session.commit() await session.refresh(event) asyncio.create_task(_push_update(event, user_id, old_title=old_title)) return event async def delete_event(user_id: int, event_id: int) -> None: """Delete event. Fires CalDAV delete push if caldav_uid is set.""" async with async_session() as session: result = await session.execute( select(Event).where(Event.id == event_id, Event.user_id == user_id) ) event = result.scalar_one_or_none() if event is None: return caldav_uid = event.caldav_uid event_title = event.title # needed to find the event on CalDAV by title await session.delete(event) await session.commit() if caldav_uid: asyncio.create_task(_push_delete(caldav_uid, event_title, user_id)) async def find_events_by_query(user_id: int, query: str) -> list[Event]: """ILIKE search on title — used by AI update/delete tools.""" q = f"%{query}%" async with async_session() as session: result = await session.execute( select(Event).where( Event.user_id == user_id, Event.title.ilike(q), ).order_by(Event.start_dt) ) return result.scalars().all() # --------------------------------------------------------------------------- # CalDAV push helpers (fire-and-forget) # --------------------------------------------------------------------------- async def _push_create(event: Event, user_id: int, extra: dict) -> None: try: from fabledassistant.services.caldav import ( create_event as caldav_create, is_caldav_configured, ) if not await is_caldav_configured(user_id): return await caldav_create( user_id=user_id, title=event.title, start=event.start_dt.isoformat(), end=event.end_dt.isoformat() if event.end_dt else None, description=event.description or None, location=event.location or None, all_day=event.all_day, recurrence=event.recurrence, uid=event.uid, duration=extra.get("duration"), reminder_minutes=extra.get("reminder_minutes"), attendees=extra.get("attendees"), calendar_name=extra.get("calendar_name"), ) # Mark as synced async with async_session() as session: result = await session.execute( select(Event).where(Event.id == event.id) ) ev = result.scalar_one_or_none() if ev: ev.caldav_uid = event.uid await session.commit() except Exception: logger.warning("CalDAV push (create) failed for event %d", event.id, exc_info=True) async def _push_update(event: Event, user_id: int, old_title: str = "") -> None: """Push an update to CalDAV. Uses old_title to locate the event by its pre-rename SUMMARY.""" if not event.caldav_uid: return try: from fabledassistant.services.caldav import ( update_event as caldav_update, is_caldav_configured, ) if not await is_caldav_configured(user_id): return # Use old_title so CalDAV can find the event even if the title was changed query_title = old_title or event.title await caldav_update( user_id=user_id, query=query_title, title=event.title, start=event.start_dt.isoformat(), end=event.end_dt.isoformat() if event.end_dt else None, description=event.description or None, location=event.location or None, ) except Exception: logger.warning("CalDAV push (update) failed for event %d", event.id, exc_info=True) async def _push_delete(caldav_uid: str, event_title: str, user_id: int) -> None: """Push a delete to CalDAV. Uses event_title to locate the event by SUMMARY.""" try: from fabledassistant.services.caldav import ( delete_event as caldav_delete, is_caldav_configured, ) if not await is_caldav_configured(user_id): return await caldav_delete(user_id=user_id, query=event_title) except Exception: logger.warning("CalDAV push (delete) failed for uid %s", caldav_uid, exc_info=True) ``` - [ ] **Step 4: Run tests to verify they pass** ```bash docker compose exec app python -m pytest tests/test_events_service.py -v ``` Expected: PASS (all 5 tests) - [ ] **Step 5: Commit** ```bash git add src/fabledassistant/services/events.py tests/test_events_service.py git commit -m "feat(calendar): implement events service with CalDAV push" ``` --- ## Task 5: Create routes/events.py + register in app.py **Files:** - Create: `src/fabledassistant/routes/events.py` - Modify: `src/fabledassistant/app.py` - Test: `tests/test_events_routes.py` - [ ] **Step 1: Write the failing tests** Create `tests/test_events_routes.py`: ```python import pytest from unittest.mock import AsyncMock, patch, MagicMock from datetime import datetime, timezone def _make_mock_event(): e = MagicMock() e.id = 1 e.user_id = 1 e.uid = "uid-abc" e.title = "Meeting" e.caldav_uid = "" e.color = "" e.start_dt = datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc) e.end_dt = None e.all_day = False e.description = "" e.location = "" e.recurrence = None e.project_id = None e.created_at = datetime(2026, 3, 25, tzinfo=timezone.utc) e.updated_at = datetime(2026, 3, 25, tzinfo=timezone.utc) e.to_dict.return_value = {"id": 1, "title": "Meeting", "user_id": 1} return e def test_events_blueprint_registered(): """events_bp must be importable from its module.""" from fabledassistant.routes.events import events_bp assert events_bp.name == "events" @pytest.mark.asyncio async def test_list_events_requires_from_and_to(): """GET /api/events without from/to returns 400.""" from fabledassistant.app import create_app app = create_app() async with app.test_client() as client: with patch("fabledassistant.routes.events.login_required", lambda f: f), \ patch("fabledassistant.routes.events._get_current_user_id", return_value=1): response = await client.get("/api/events") assert response.status_code == 400 @pytest.mark.asyncio async def test_get_event_404_for_nonowner(): """GET /api/events/:id returns 404 when owned by different user.""" from fabledassistant.app import create_app app = create_app() async with app.test_client() as client: with patch("fabledassistant.routes.events.login_required", lambda f: f), \ patch("fabledassistant.routes.events._get_current_user_id", return_value=2), \ patch("fabledassistant.routes.events.events_svc.get_event", new_callable=AsyncMock) as mock_get: mock_get.return_value = None # service returns None for non-owner response = await client.get("/api/events/1") assert response.status_code == 404 @pytest.mark.asyncio async def test_patch_event_404_for_nonowner(): """PATCH /api/events/:id returns 404 for non-owner.""" from fabledassistant.app import create_app app = create_app() async with app.test_client() as client: with patch("fabledassistant.routes.events.login_required", lambda f: f), \ patch("fabledassistant.routes.events._get_current_user_id", return_value=2), \ patch("fabledassistant.routes.events.events_svc.update_event", new_callable=AsyncMock) as mock_upd: mock_upd.return_value = None # service returns None for non-owner response = await client.patch("/api/events/1", json={"title": "New"}) assert response.status_code == 404 @pytest.mark.asyncio async def test_delete_event_404_for_nonowner(): """DELETE /api/events/:id returns 404 for non-owner.""" from fabledassistant.app import create_app app = create_app() async with app.test_client() as client: with patch("fabledassistant.routes.events.login_required", lambda f: f), \ patch("fabledassistant.routes.events._get_current_user_id", return_value=2), \ patch("fabledassistant.routes.events.events_svc.get_event", new_callable=AsyncMock) as mock_get: mock_get.return_value = None # service returns None for non-owner response = await client.delete("/api/events/1") assert response.status_code == 404 ``` - [ ] **Step 2: Run test to verify it fails** ```bash docker compose exec app python -m pytest tests/test_events_routes.py::test_events_blueprint_registered -v ``` Expected: FAIL with ImportError - [ ] **Step 3: Implement routes/events.py** Create `src/fabledassistant/routes/events.py`: ```python """Calendar events REST API.""" from __future__ import annotations from datetime import datetime from quart import Blueprint, g, jsonify, request from fabledassistant.auth import login_required import fabledassistant.services.events as events_svc events_bp = Blueprint("events", __name__, url_prefix="/api/events") def _get_current_user_id() -> int: return g.user_id @events_bp.get("") @login_required async def list_events(): date_from_str = request.args.get("from") date_to_str = request.args.get("to") if not date_from_str or not date_to_str: return jsonify({"error": "from and to query params are required"}), 400 try: date_from = datetime.fromisoformat(date_from_str) date_to = datetime.fromisoformat(date_to_str) except ValueError: return jsonify({"error": "Invalid datetime format"}), 400 events = await events_svc.list_events( user_id=_get_current_user_id(), date_from=date_from, date_to=date_to, ) return jsonify([e.to_dict() for e in events]) @events_bp.post("") @login_required async def create_event(): data = await request.get_json() or {} if not data.get("title") or not data.get("start_dt"): return jsonify({"error": "title and start_dt are required"}), 400 try: start_dt = datetime.fromisoformat(data["start_dt"]) end_dt = datetime.fromisoformat(data["end_dt"]) if data.get("end_dt") else None except ValueError: return jsonify({"error": "Invalid datetime format"}), 400 event = await events_svc.create_event( user_id=_get_current_user_id(), title=data["title"], start_dt=start_dt, end_dt=end_dt, all_day=data.get("all_day", False), description=data.get("description", ""), location=data.get("location", ""), color=data.get("color", ""), recurrence=data.get("recurrence"), project_id=data.get("project_id"), ) return jsonify(event.to_dict()), 201 @events_bp.get("/") @login_required async def get_event(event_id: int): event = await events_svc.get_event( user_id=_get_current_user_id(), event_id=event_id, ) if event is None: return jsonify({"error": "Event not found"}), 404 return jsonify(event.to_dict()) @events_bp.patch("/") @login_required async def update_event(event_id: int): data = await request.get_json() or {} fields: dict = {} for str_field in ("title", "description", "location", "color", "recurrence"): if str_field in data: fields[str_field] = data[str_field] for bool_field in ("all_day",): if bool_field in data: fields[bool_field] = data[bool_field] for int_field in ("project_id",): if int_field in data: fields[int_field] = data[int_field] for dt_field in ("start_dt", "end_dt"): if dt_field in data and data[dt_field]: try: fields[dt_field] = datetime.fromisoformat(data[dt_field]) except ValueError: return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400 event = await events_svc.update_event( user_id=_get_current_user_id(), event_id=event_id, **fields, ) if event is None: return jsonify({"error": "Event not found"}), 404 return jsonify(event.to_dict()) @events_bp.delete("/") @login_required async def delete_event(event_id: int): event = await events_svc.get_event( user_id=_get_current_user_id(), event_id=event_id, ) if event is None: return jsonify({"error": "Event not found"}), 404 await events_svc.delete_event( user_id=_get_current_user_id(), event_id=event_id, ) return "", 204 ``` - [ ] **Step 4: Register the blueprint in app.py** In `src/fabledassistant/app.py`, add to the imports: ```python from fabledassistant.routes.events import events_bp ``` And in `create_app()`, alongside the other `app.register_blueprint(...)` calls, add: ```python app.register_blueprint(events_bp) ``` - [ ] **Step 5: Run tests** ```bash docker compose exec app python -m pytest tests/test_events_routes.py -v ``` Expected: PASS - [ ] **Step 6: Commit** ```bash git add src/fabledassistant/routes/events.py src/fabledassistant/app.py tests/test_events_routes.py git commit -m "feat(calendar): events REST API blueprint" ``` --- ## Task 6: Rewire AI tools to events service **Files:** - Modify: `src/fabledassistant/services/tools.py` The changes: 1. Replace the `caldav` imports for event functions with `events` service imports 2. Move `_CALDAV_TOOLS` contents (the 5 event tools) into `_CORE_TOOLS` 3. Remove the `list_calendars` tool from `_CALDAV_TOOLS` (CalDAV-only, leave it conditional) 4. Remove `is_caldav_configured` from imports (still imported for `list_calendars` gate) 5. Update `execute_tool` for `create_event`, `list_events`, `search_events`, `update_event`, `delete_event` - [ ] **Step 1: Write the failing test** Add to `tests/test_events_service.py`: ```python @pytest.mark.asyncio async def test_tools_calendar_always_available(): """Calendar tools must appear in get_tools_for_user even without CalDAV.""" with patch("fabledassistant.services.tools.is_caldav_configured", new_callable=AsyncMock) as mock_configured: mock_configured.return_value = False from fabledassistant.services.tools import get_tools_for_user tools = await get_tools_for_user(user_id=1) tool_names = {t["function"]["name"] for t in tools} assert "create_event" in tool_names assert "list_events" in tool_names assert "search_events" in tool_names assert "update_event" in tool_names assert "delete_event" in tool_names ``` - [ ] **Step 2: Run test to verify it fails** ```bash docker compose exec app python -m pytest tests/test_events_service.py::test_tools_calendar_always_available -v ``` Expected: FAIL (tools only present when caldav is configured) - [ ] **Step 3: Rewire tools.py** In `src/fabledassistant/services/tools.py`: **A. Replace event-related caldav imports** (lines 9-17): ```python from fabledassistant.services.caldav import ( is_caldav_configured, list_calendars, ) from fabledassistant.services.events import ( create_event as events_create_event, list_events as events_list_events, search_events as events_search_events, update_event as events_update_event, delete_event as events_delete_event, find_events_by_query, ) ``` **B. Move the 5 event tool definitions** (currently in `_CALDAV_TOOLS` at lines 579-757) from `_CALDAV_TOOLS` into `_CORE_TOOLS` (append before the closing `]` of `_CORE_TOOLS`). Leave `list_calendars` alone in a renamed `_CALDAV_TOOLS` that only contains it: ```python # CalDAV tools — only when CalDAV is configured (server listing) _CALDAV_TOOLS = [ { "type": "function", "function": { "name": "list_calendars", "description": "List all available calendars. Use this when the user asks which calendars they have.", "parameters": {"type": "object", "properties": {}}, }, }, ] ``` **C. Update `get_tools_for_user`** (lines 834-844) — the function body stays the same; `_CALDAV_TOOLS` now only contains `list_calendars` so the gate is still correct. **D. Update `execute_tool` for the 5 event handlers** to call the `events_*` functions: Replace the `create_event` block (around line 1225): ```python elif tool_name == "create_event": from fabledassistant.services.events import create_event as _create_ev from datetime import datetime, timezone start_str = arguments["start"] end_str = arguments.get("end") all_day = arguments.get("all_day", False) tz_str = arguments.get("timezone") def _parse_dt(s: str) -> datetime: dt = datetime.fromisoformat(s) if dt.tzinfo is None and tz_str: from zoneinfo import ZoneInfo dt = dt.replace(tzinfo=ZoneInfo(tz_str)) elif dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) start_dt = _parse_dt(start_str) end_dt = _parse_dt(end_str) if end_str else None event = await _create_ev( user_id=user_id, title=arguments.get("title", "Untitled Event"), start_dt=start_dt, end_dt=end_dt, all_day=all_day, description=arguments.get("description", ""), location=arguments.get("location", ""), color=arguments.get("color", ""), recurrence=arguments.get("recurrence"), duration=arguments.get("duration"), reminder_minutes=arguments.get("reminder_minutes"), attendees=arguments.get("attendees"), calendar_name=arguments.get("calendar_name"), ) return {"success": True, "type": "event", "data": event.to_dict()} ``` Replace the `list_events` block: ```python elif tool_name == "list_events": from fabledassistant.services.events import list_events as _list_ev from datetime import datetime, timezone dt_from = datetime.fromisoformat(arguments["date_from"]) dt_to = datetime.fromisoformat(arguments["date_to"]) if dt_from.tzinfo is None: dt_from = dt_from.replace(tzinfo=timezone.utc) if dt_to.tzinfo is None: dt_to = dt_to.replace(tzinfo=timezone.utc) events = await _list_ev(user_id=user_id, date_from=dt_from, date_to=dt_to) return {"success": True, "type": "events", "data": {"count": len(events), "events": [e.to_dict() for e in events]}} ``` Replace `search_events`: ```python elif tool_name == "search_events": from fabledassistant.services.events import search_events as _search_ev events = await _search_ev(user_id=user_id, query=arguments.get("query", "")) return {"success": True, "type": "events", "data": {"count": len(events), "events": [e.to_dict() for e in events]}} ``` Replace `update_event`: ```python elif tool_name == "update_event": from fabledassistant.services.events import find_events_by_query as _find_ev, update_event as _upd_ev query = arguments["query"] matches = await _find_ev(user_id=user_id, query=query) if not matches: return {"success": False, "error": f"No event found matching '{query}'."} if len(matches) > 3: titles = [e.title for e in matches] return {"success": False, "error": f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}"} event = matches[0] from datetime import datetime, timezone fields: dict = {} for k in ("title", "description", "location", "color"): if arguments.get(k) is not None: fields[k] = arguments[k] for dt_k, arg_k in (("start_dt", "start"), ("end_dt", "end")): if arguments.get(arg_k): dt = datetime.fromisoformat(arguments[arg_k]) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) fields[dt_k] = dt.astimezone(timezone.utc) updated = await _upd_ev(user_id=user_id, event_id=event.id, **fields) return {"success": True, "type": "event_updated", "data": updated.to_dict() if updated else {}} ``` Replace `delete_event`: ```python elif tool_name == "delete_event": from fabledassistant.services.events import find_events_by_query as _find_ev2, delete_event as _del_ev query = arguments["query"] matches = await _find_ev2(user_id=user_id, query=query) if not matches: return {"success": False, "error": f"No event found matching '{query}'."} if len(matches) > 3: titles = [e.title for e in matches] return {"success": False, "error": f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}"} event = matches[0] await _del_ev(user_id=user_id, event_id=event.id) return {"success": True, "type": "event_deleted", "data": {"id": event.id, "title": event.title}} ``` - [ ] **Step 4: Run tests** ```bash docker compose exec app python -m pytest tests/test_events_service.py -v ``` Expected: PASS (all tests including the new one) - [ ] **Step 5: Commit** ```bash git add src/fabledassistant/services/tools.py tests/test_events_service.py git commit -m "feat(calendar): rewire AI calendar tools to internal events service" ``` --- ## Task 7: Frontend API helpers in client.ts **Files:** - Modify: `frontend/src/api/client.ts` - [ ] **Step 1: Add EventEntry interface and 5 helpers** At the end of `frontend/src/api/client.ts`, add: ```typescript // ── Calendar Events ────────────────────────────────────────────────────────── export interface EventEntry { id: number; uid: string; title: string; start_dt: string; end_dt: string | null; all_day: boolean; description: string; location: string; color: string; recurrence: string | null; project_id: number | null; caldav_uid: string; created_at: string; updated_at: string; } export interface CreateEventBody { title: string; start_dt: string; end_dt?: string | null; all_day?: boolean; description?: string; location?: string; color?: string; recurrence?: string | null; project_id?: number | null; } export function listEvents(from: string, to: string): Promise { return apiGet(`/api/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`); } export function createEvent(body: CreateEventBody): Promise { return apiPost("/api/events", body); } export function getEvent(id: number): Promise { return apiGet(`/api/events/${id}`); } export function updateEvent(id: number, body: Partial): Promise { return apiPatch(`/api/events/${id}`, body); } export function deleteEvent(id: number): Promise { return apiDelete(`/api/events/${id}`); } ``` - [ ] **Step 2: Run typecheck** ```bash docker compose exec frontend npx vue-tsc --noEmit ``` Expected: no errors - [ ] **Step 3: Commit** ```bash git add frontend/src/api/client.ts git commit -m "feat(calendar): add EventEntry types and API helpers to client.ts" ``` --- ## Task 8: Install FullCalendar packages **Files:** - Modify: `frontend/package.json` - [ ] **Step 1: Install FullCalendar** ```bash docker compose exec frontend npm install @fullcalendar/vue3 @fullcalendar/daygrid @fullcalendar/timegrid @fullcalendar/interaction ``` - [ ] **Step 2: Verify the packages appear in package.json** ```bash grep fullcalendar frontend/package.json ``` Expected: 4 lines with `@fullcalendar/...` - [ ] **Step 3: Run typecheck** ```bash docker compose exec frontend npx vue-tsc --noEmit ``` Expected: no errors - [ ] **Step 4: Commit** ```bash git add frontend/package.json frontend/package-lock.json git commit -m "feat(calendar): add FullCalendar dependencies" ``` --- ## Task 9: Create EventSlideOver.vue **Files:** - Create: `frontend/src/components/EventSlideOver.vue` - [ ] **Step 1: Create EventSlideOver.vue** Create `frontend/src/components/EventSlideOver.vue`: ```vue