Files
FabledScribe/docs/plans/2026-03-25-internal-calendar.md
T

58 KiB

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

"""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
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:

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
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:

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,):

"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:

"user_id": self.user_id,
  • Step 4: Run test to verify it passes
docker compose exec app python -m pytest tests/test_events_model.py -v

Expected: PASS

  • Step 5: Commit
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:

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

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

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
docker compose exec app python -m pytest tests/test_events_model.py -v

Expected: PASS (both tests)

  • Step 5: Commit
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:

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
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:

"""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
docker compose exec app python -m pytest tests/test_events_service.py -v

Expected: PASS (all 5 tests)

  • Step 5: Commit
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:

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
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:

"""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("/<int:event_id>")
@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("/<int:event_id>")
@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("/<int:event_id>")
@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:

from fabledassistant.routes.events import events_bp

And in create_app(), alongside the other app.register_blueprint(...) calls, add:

app.register_blueprint(events_bp)
  • Step 5: Run tests
docker compose exec app python -m pytest tests/test_events_routes.py -v

Expected: PASS

  • Step 6: Commit
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:

@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
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):

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:

# 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):

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:

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:

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:

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:

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
docker compose exec app python -m pytest tests/test_events_service.py -v

Expected: PASS (all tests including the new one)

  • Step 5: Commit
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:

// ── 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<EventEntry[]> {
  return apiGet(`/api/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
}

export function createEvent(body: CreateEventBody): Promise<EventEntry> {
  return apiPost("/api/events", body);
}

export function getEvent(id: number): Promise<EventEntry> {
  return apiGet(`/api/events/${id}`);
}

export function updateEvent(id: number, body: Partial<CreateEventBody>): Promise<EventEntry> {
  return apiPatch(`/api/events/${id}`, body);
}

export function deleteEvent(id: number): Promise<void> {
  return apiDelete(`/api/events/${id}`);
}
  • Step 2: Run typecheck
docker compose exec frontend npx vue-tsc --noEmit

Expected: no errors

  • Step 3: Commit
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

docker compose exec frontend npm install @fullcalendar/vue3 @fullcalendar/daygrid @fullcalendar/timegrid @fullcalendar/interaction
  • Step 2: Verify the packages appear in package.json
grep fullcalendar frontend/package.json

Expected: 4 lines with @fullcalendar/...

  • Step 3: Run typecheck
docker compose exec frontend npx vue-tsc --noEmit

Expected: no errors

  • Step 4: Commit
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:

<script setup lang="ts">
import { ref, watch, computed, onMounted } from "vue";
import {
  createEvent,
  updateEvent,
  deleteEvent,
  type EventEntry,
  type CreateEventBody,
} from "@/api/client";
import { apiGet } from "@/api/client";

interface ProjectOption { id: number; title: string; }

interface Props {
  modelValue: boolean;
  event?: EventEntry | null;
  defaultDate?: string;
}

const props = withDefaults(defineProps<Props>(), {
  event: null,
  defaultDate: "",
});

const emit = defineEmits<{
  (e: "update:modelValue", val: boolean): void;
  (e: "saved"): void;
  (e: "deleted"): void;
}>();

const isOpen = computed({
  get: () => props.modelValue,
  set: (v) => emit("update:modelValue", v),
});

const isEdit = computed(() => !!props.event);
const saving = ref(false);
const deleting = ref(false);
const error = ref("");
const projects = ref<ProjectOption[]>([]);

async function loadProjects() {
  try {
    const data = await apiGet("/api/projects");
    projects.value = (data.projects ?? data) as ProjectOption[];
  } catch { /* non-blocking */ }
}

onMounted(loadProjects);

const form = ref<CreateEventBody & { project_id?: number | null }>({
  title: "",
  start_dt: "",
  end_dt: "",
  all_day: false,
  description: "",
  location: "",
  color: "",
  project_id: null,
});

watch(
  () => props.modelValue,
  (open) => {
    if (!open) return;
    error.value = "";
    if (props.event) {
      form.value = {
        title: props.event.title,
        start_dt: toLocalInput(props.event.start_dt),
        end_dt: props.event.end_dt ? toLocalInput(props.event.end_dt) : "",
        all_day: props.event.all_day,
        description: props.event.description,
        location: props.event.location,
        color: props.event.color,
        project_id: props.event.project_id,
      };
    } else {
      form.value = {
        title: "",
        start_dt: props.defaultDate ? `${props.defaultDate}T09:00` : "",
        end_dt: "",
        all_day: false,
        description: "",
        location: "",
        color: "",
        project_id: null,
      };
    }
  }
);

function toLocalInput(isoStr: string): string {
  if (!isoStr) return "";
  const d = new Date(isoStr);
  const pad = (n: number) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}

function toIso(localStr: string): string {
  if (!localStr) return "";
  // Create a timezone-aware string from local input
  return new Date(localStr).toISOString();
}

async function handleSave() {
  if (!form.value.title.trim()) {
    error.value = "Title is required.";
    return;
  }
  saving.value = true;
  error.value = "";
  try {
    const body: CreateEventBody = {
      title: form.value.title.trim(),
      start_dt: toIso(form.value.start_dt as string),
      end_dt: form.value.end_dt ? toIso(form.value.end_dt as string) : null,
      all_day: form.value.all_day,
      description: form.value.description,
      location: form.value.location,
      color: form.value.color,
      project_id: form.value.project_id ?? null,
    };
    if (isEdit.value && props.event) {
      await updateEvent(props.event.id, body);
    } else {
      await createEvent(body);
    }
    isOpen.value = false;
    emit("saved");
  } catch (e: unknown) {
    error.value = e instanceof Error ? e.message : "Failed to save event.";
  } finally {
    saving.value = false;
  }
}

async function handleDelete() {
  if (!props.event) return;
  if (!confirm(`Delete "${props.event.title}"?`)) return;
  deleting.value = true;
  try {
    await deleteEvent(props.event.id);
    isOpen.value = false;
    emit("deleted");
  } catch (e: unknown) {
    error.value = e instanceof Error ? e.message : "Failed to delete event.";
  } finally {
    deleting.value = false;
  }
}

function handleKeydown(e: KeyboardEvent) {
  if (e.key === "Escape") isOpen.value = false;
}
</script>

<template>
  <Teleport to="body">
    <Transition name="slide-over">
      <div
        v-if="isOpen"
        class="slide-over-backdrop"
        @click.self="isOpen = false"
        @keydown="handleKeydown"
      >
        <div class="slide-over-panel" role="dialog" aria-modal="true">
          <header class="slide-over-header">
            <h2 class="slide-over-title">{{ isEdit ? "Edit Event" : "New Event" }}</h2>
            <button class="btn-icon" @click="isOpen = false" aria-label="Close">&times;</button>
          </header>

          <div class="slide-over-body">
            <div v-if="error" class="slide-over-error">{{ error }}</div>

            <div class="form-group">
              <label class="form-label">Title <span class="required">*</span></label>
              <input
                v-model="form.title"
                type="text"
                class="form-input"
                placeholder="Event title"
                autofocus
              />
            </div>

            <div class="form-row">
              <div class="form-group">
                <label class="form-label">Start</label>
                <input
                  v-model="form.start_dt"
                  :type="form.all_day ? 'date' : 'datetime-local'"
                  class="form-input"
                />
              </div>
              <div class="form-group">
                <label class="form-label">End</label>
                <input
                  v-model="form.end_dt"
                  :type="form.all_day ? 'date' : 'datetime-local'"
                  class="form-input"
                />
              </div>
            </div>

            <div class="form-group form-check">
              <input id="all-day" v-model="form.all_day" type="checkbox" />
              <label for="all-day" class="form-check-label">All day</label>
            </div>

            <div class="form-group">
              <label class="form-label">Location</label>
              <input
                v-model="form.location"
                type="text"
                class="form-input"
                placeholder="Optional location"
              />
            </div>

            <div class="form-group">
              <label class="form-label">Description</label>
              <textarea
                v-model="form.description"
                class="form-input"
                rows="3"
                placeholder="Optional description"
              />
            </div>

            <div class="form-group">
              <label class="form-label">Colour</label>
              <div class="color-picker-row">
                <input v-model="form.color" type="color" class="color-swatch" />
                <input
                  v-model="form.color"
                  type="text"
                  class="form-input color-hex"
                  placeholder="#6366f1"
                  maxlength="7"
                />
              </div>
            </div>

            <div class="form-group" v-if="projects.length > 0">
              <label class="form-label">Project</label>
              <select v-model="form.project_id" class="form-input">
                <option :value="null"> none </option>
                <option v-for="p in projects" :key="p.id" :value="p.id">{{ p.title }}</option>
              </select>
            </div>
          </div>

          <footer class="slide-over-footer">
            <button
              v-if="isEdit"
              class="btn btn-danger"
              :disabled="deleting"
              @click="handleDelete"
            >
              {{ deleting ? "Deleting..." : "Delete" }}
            </button>
            <div class="footer-right">
              <button class="btn btn-ghost" @click="isOpen = false">Cancel</button>
              <button class="btn btn-primary" :disabled="saving" @click="handleSave">
                {{ saving ? "Saving..." : isEdit ? "Save Changes" : "Create Event" }}
              </button>
            </div>
          </footer>
        </div>
      </div>
    </Transition>
  </Teleport>
</template>

<style scoped>
.slide-over-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.45);
  z-index: 500;
  display: flex;
  justify-content: flex-end;
}

.slide-over-panel {
  width: min(480px, 100vw);
  height: 100%;
  background: var(--color-bg-primary);
  display: flex;
  flex-direction: column;
  overflow-y: auto;
  box-shadow: -4px 0 24px rgba(0, 0, 0, 0.3);
}

.slide-over-header {
  padding: 1.25rem 1.5rem;
  display: flex;
  align-items: center;
  justify-content: space-between;
  border-bottom: 1px solid var(--color-border);
}

.slide-over-title {
  font-family: Fraunces, serif;
  font-size: 1.1rem;
  margin: 0;
}

.slide-over-body {
  flex: 1;
  padding: 1.5rem;
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.slide-over-error {
  background: rgba(239, 68, 68, 0.1);
  border: 1px solid rgba(239, 68, 68, 0.3);
  border-radius: 6px;
  padding: 0.5rem 0.75rem;
  color: #ef4444;
  font-size: 0.875rem;
}

.slide-over-footer {
  padding: 1rem 1.5rem;
  border-top: 1px solid var(--color-border);
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.footer-right {
  display: flex;
  gap: 0.5rem;
}

.form-group {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.form-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.75rem;
}

.form-label {
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--color-text-muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.required {
  color: #ef4444;
}

.form-input {
  background: var(--color-bg-secondary);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: 0.5rem 0.75rem;
  color: var(--color-text-primary);
  font-size: 0.9rem;
  width: 100%;
  box-sizing: border-box;
}

.form-input:focus {
  outline: none;
  border-color: var(--color-primary);
}

.form-check {
  flex-direction: row;
  align-items: center;
  gap: 0.5rem;
}

.form-check-label {
  font-size: 0.9rem;
  color: var(--color-text-primary);
}

.color-picker-row {
  display: flex;
  gap: 0.5rem;
  align-items: center;
}

.color-swatch {
  width: 2.5rem;
  height: 2.5rem;
  border: none;
  padding: 0;
  border-radius: var(--radius-md);
  cursor: pointer;
}

.color-hex {
  flex: 1;
}

.btn {
  padding: 0.5rem 1rem;
  border-radius: var(--radius-md);
  font-size: 0.875rem;
  font-weight: 500;
  border: none;
  cursor: pointer;
  transition: opacity 0.15s;
}

.btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.btn-primary {
  background: linear-gradient(135deg, #6366f1, #4f46e5);
  color: #fff;
}

.btn-ghost {
  background: transparent;
  border: 1px solid var(--color-border);
  color: var(--color-text-primary);
}

.btn-danger {
  background: rgba(239, 68, 68, 0.15);
  color: #ef4444;
  border: 1px solid rgba(239, 68, 68, 0.3);
}

.slide-over-enter-active,
.slide-over-leave-active {
  transition: transform 0.25s ease;
}

.slide-over-enter-from,
.slide-over-leave-to {
  transform: translateX(100%);
}
</style>
  • Step 2: Run typecheck
docker compose exec frontend npx vue-tsc --noEmit

Expected: no errors

  • Step 3: Commit
git add frontend/src/components/EventSlideOver.vue
git commit -m "feat(calendar): EventSlideOver component"

Task 10: Create CalendarView.vue

Files:

  • Create: frontend/src/views/CalendarView.vue

  • Step 1: Create CalendarView.vue

Create frontend/src/views/CalendarView.vue:

<script setup lang="ts">
import { ref, onMounted } from "vue";
import FullCalendar from "@fullcalendar/vue3";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
import EventSlideOver from "@/components/EventSlideOver.vue";
import { listEvents, updateEvent, type EventEntry } from "@/api/client";
import type { CalendarOptions, EventInput, EventDropArg, EventResizeDoneArg } from "@fullcalendar/core";

const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);

const slideOverOpen = ref(false);
const activeEvent = ref<EventEntry | null>(null);
const defaultDate = ref("");

function toCalendarEvent(e: EventEntry): EventInput {
  return {
    id: String(e.id),
    title: e.title,
    start: e.start_dt,
    end: e.end_dt ?? undefined,
    allDay: e.all_day,
    backgroundColor: e.color || undefined,
    borderColor: e.color || undefined,
    extendedProps: { raw: e },
  };
}

async function loadEvents(
  fetchInfo: { start: Date; end: Date },
  successCallback: (events: EventInput[]) => void,
  failureCallback: (error: Error) => void
) {
  try {
    const events = await listEvents(
      fetchInfo.start.toISOString(),
      fetchInfo.end.toISOString()
    );
    successCallback(events.map(toCalendarEvent));
  } catch (e) {
    failureCallback(e instanceof Error ? e : new Error("Failed to load events"));
  }
}

function handleDateClick(info: { dateStr: string }) {
  activeEvent.value = null;
  defaultDate.value = info.dateStr.substring(0, 10);
  slideOverOpen.value = true;
}

function handleEventClick(info: { event: { id: string; extendedProps: { raw: EventEntry } } }) {
  activeEvent.value = info.event.extendedProps.raw;
  defaultDate.value = "";
  slideOverOpen.value = true;
}

async function handleEventDrop(info: EventDropArg) {
  const raw = info.event.extendedProps.raw as EventEntry;
  try {
    await updateEvent(raw.id, {
      start_dt: info.event.start!.toISOString(),
      end_dt: info.event.end?.toISOString() ?? null,
    });
  } catch {
    info.revert();
  }
}

async function handleEventResize(info: EventResizeDoneArg) {
  const raw = info.event.extendedProps.raw as EventEntry;
  try {
    await updateEvent(raw.id, {
      start_dt: info.event.start!.toISOString(),
      end_dt: info.event.end?.toISOString() ?? null,
    });
  } catch {
    info.revert();
  }
}

function handleSaved() {
  calendarRef.value?.getApi().refetchEvents();
}

function handleDeleted() {
  calendarRef.value?.getApi().refetchEvents();
}

const calendarOptions: CalendarOptions = {
  plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
  initialView: "dayGridMonth",
  timeZone: "local",
  headerToolbar: {
    left: "prev,next today",
    center: "title",
    right: "dayGridMonth,timeGridWeek",
  },
  events: loadEvents,
  editable: true,
  selectable: true,
  dateClick: handleDateClick,
  eventClick: handleEventClick,
  eventDrop: handleEventDrop,
  eventResize: handleEventResize,
  height: "auto",
};
</script>

<template>
  <div class="calendar-page">
    <div class="calendar-header">
      <h1 class="calendar-title">Calendar</h1>
    </div>
    <div class="calendar-wrapper">
      <FullCalendar ref="calendarRef" :options="calendarOptions" />
    </div>

    <EventSlideOver
      v-model="slideOverOpen"
      :event="activeEvent"
      :default-date="defaultDate"
      @saved="handleSaved"
      @deleted="handleDeleted"
    />
  </div>
</template>

<style scoped>
.calendar-page {
  max-width: 1200px;
  margin: 0 auto;
  padding: 1.5rem;
}

.calendar-header {
  margin-bottom: 1.25rem;
}

.calendar-title {
  font-family: Fraunces, serif;
  font-size: 1.5rem;
  font-weight: 600;
  margin: 0;
}

.calendar-wrapper {
  background: var(--color-bg-secondary);
  border-radius: var(--radius-lg);
  padding: 1rem;
  border: 1px solid var(--color-border);
}

/* FullCalendar theme overrides */
:deep(.fc) {
  --fc-border-color: var(--color-border);
  --fc-button-bg-color: var(--color-bg-tertiary);
  --fc-button-border-color: var(--color-border);
  --fc-button-hover-bg-color: var(--color-primary);
  --fc-button-hover-border-color: var(--color-primary);
  --fc-button-active-bg-color: var(--color-primary);
  --fc-button-active-border-color: var(--color-primary);
  --fc-today-bg-color: rgba(99, 102, 241, 0.08);
  --fc-page-bg-color: transparent;
  color: var(--color-text-primary);
}

:deep(.fc-col-header-cell-cushion),
:deep(.fc-daygrid-day-number) {
  color: var(--color-text-primary);
}

:deep(.fc-event) {
  cursor: pointer;
  border-radius: 4px;
}
</style>
  • Step 2: Run typecheck
docker compose exec frontend npx vue-tsc --noEmit

Expected: no errors

  • Step 3: Commit
git add frontend/src/views/CalendarView.vue
git commit -m "feat(calendar): CalendarView with FullCalendar month/week grid"

Files:

  • Modify: frontend/src/router/index.ts

  • Modify: frontend/src/components/AppHeader.vue

  • Modify: frontend/src/App.vue

  • Step 1: Add /calendar route

In frontend/src/router/index.ts, after the /briefing route block (around line 113), add:

{
  path: "/calendar",
  name: "calendar",
  component: () => import("@/views/CalendarView.vue"),
},
  • Step 2: Add Calendar nav link to AppHeader

In frontend/src/components/AppHeader.vue, in the desktop nav center (after the Briefing link, line 80):

<router-link to="/calendar" class="nav-link">Calendar</router-link>

In the mobile menu (after line 129):

<router-link to="/calendar" class="nav-link">Calendar</router-link>
  • Step 3: Add g+l keyboard shortcut in App.vue

In frontend/src/App.vue, in the g+X switch statement (around line 84), add after case "g": ...:

case "l": router.push("/calendar"); break;
  • Step 4: Run typecheck
docker compose exec frontend npx vue-tsc --noEmit

Expected: no errors

  • Step 5: Commit
git add frontend/src/router/index.ts frontend/src/components/AppHeader.vue frontend/src/App.vue
git commit -m "feat(calendar): add /calendar route, nav link, g+l shortcut"

Task 12: Full test run and final commit

  • Step 1: Run the full backend test suite
docker compose exec app python -m pytest -v

Expected: all tests pass

  • Step 2: Run typecheck
docker compose exec frontend npx vue-tsc --noEmit

Expected: no errors

  • Step 3: Final commit if any cleanup needed
git add -A
git commit -m "feat(calendar): internal calendar with CalDAV sync — complete"