Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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:
- Replace the
caldavimports for event functions witheventsservice imports - Move
_CALDAV_TOOLScontents (the 5 event tools) into_CORE_TOOLS - Remove the
list_calendarstool from_CALDAV_TOOLS(CalDAV-only, leave it conditional) - Remove
is_caldav_configuredfrom imports (still imported forlist_calendarsgate) - Update
execute_toolforcreate_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">×</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"
Task 11: Wire up route, nav link, and keyboard shortcut
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"