diff --git a/docs/plans/2026-03-25-internal-calendar.md b/docs/plans/2026-03-25-internal-calendar.md new file mode 100644 index 0000000..b77a6f4 --- /dev/null +++ b/docs/plans/2026-03-25-internal-calendar.md @@ -0,0 +1,1953 @@ +# 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 + + +