Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
11 KiB
Internal Calendar with CalDAV Sync — Design Spec
Goal
Add an internal event store to Fable's database, a full calendar UI (month/week grid), and a best-effort push sync to the user's existing external CalDAV server. Fable becomes the source of truth for events; CalDAV is an optional downstream target.
Background
The existing services/caldav.py lets the AI assistant create, update, and delete events directly on an external CalDAV server (Nextcloud, etc.) via tool calls. There is no calendar UI and no internal event storage — all reads go to CalDAV at query time. The events DB table and models/event.py exist as dead code from an abandoned Radicale integration (created in migration 0019_add_events); the table exists in the database but all columns are empty.
This design revives the internal event store, adds a REST API and calendar view, and re-wires the AI tools to write through the internal DB so all events are accessible regardless of CalDAV availability.
Architecture
Source of Truth
Fable's events DB table is the single source of truth. CalDAV sync is a best-effort push: after every create, update, or delete, a background task attempts to mirror the change to the user's configured CalDAV server. If CalDAV is unconfigured or unreachable, the operation still succeeds and the event remains in Fable.
Timezone Handling
- Storage:
start_dt/end_dtstored as UTC (TIMESTAMPTZ). - Display: FullCalendar configured with
timeZone: 'local'— renders in the browser's IANA timezone automatically. - Input: Frontend datetime inputs are in local time; converted to a timezone-aware ISO string (with UTC offset) before the API call. Backend parses to UTC.
- CalDAV push: Uses the user's
caldav_timezonesetting for iCalDTSTART/DTEND, consistent with existing behaviour. - AI tools: The LLM receives the user's timezone in the system prompt and sends timezone-aware strings, as today.
Data Model
events table (existing since migration 0019, extend via migration 0029)
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK |
|
user_id |
INT FK users CASCADE |
|
project_id |
INT FK projects SET NULL nullable |
Optional link to a Fable project |
uid |
TEXT |
iCal UID — UUID generated on create_event, embedded in CalDAV push |
caldav_uid |
TEXT DEFAULT '' |
New. Same value as uid after a successful CalDAV push (confirms sync); empty if never synced |
title |
TEXT |
|
start_dt |
TIMESTAMPTZ |
UTC |
end_dt |
TIMESTAMPTZ nullable |
UTC; null for open-ended events |
all_day |
BOOLEAN DEFAULT false |
When true, time component is ignored in display |
description |
TEXT DEFAULT '' |
|
location |
TEXT DEFAULT '' |
|
color |
TEXT DEFAULT '' |
New. Hex colour for calendar display (e.g. #6366f1) |
recurrence |
TEXT nullable |
iCal RRULE string — stored and forwarded to CalDAV; not exposed in the UI slide-over |
created_at |
TIMESTAMPTZ |
|
updated_at |
TIMESTAMPTZ |
Migration 0029_add_calendar_columns: ALTER TABLE events ADD COLUMN IF NOT EXISTS caldav_uid TEXT DEFAULT '' and ADD COLUMN IF NOT EXISTS color TEXT DEFAULT ''. Raw SQL with IF NOT EXISTS guards (table already exists from migration 0019).
models/event.py: Add two mapped_column declarations to the Event class:
caldav_uid: Mapped[str] = mapped_column(Text, default="")
color: Mapped[str] = mapped_column(Text, default="")
Update to_dict() to include "caldav_uid": self.caldav_uid and "color": self.color.
Service Layer
src/fabledassistant/services/events.py — owns all event CRUD and CalDAV push coordination.
Public functions
create_event(user_id, title, start_dt, end_dt, all_day, description, location,
color, recurrence, project_id,
duration, reminder_minutes, attendees, calendar_name) → Event
get_event(user_id, event_id) → Event
list_events(user_id, date_from, date_to) → list[Event]
search_events(user_id, query, days_ahead=90) → list[Event]
update_event(user_id, event_id, **fields) → Event
delete_event(user_id, event_id) → None
find_events_by_query(user_id, query) → list[Event]
duration, reminder_minutes, attendees, and calendar_name are accepted by create_event for forwarding to CalDAV (they are not stored in the DB). find_events_by_query does a case-insensitive ILIKE search on title and is used by the AI update_event / delete_event tools.
CalDAV push — UID strategy
create_event generates a UUID and stores it as the event's uid. This same UUID must be embedded as the iCal UID field when pushing to CalDAV. To enable this, services/caldav.py's create_event function is minimally modified to accept an optional uid: str | None = None parameter; when provided, event.add("uid", uid) is called before cal.add_component(event). All other CalDAV functions are unchanged.
On a successful push, caldav_uid is set to the same UUID value in the DB (confirming sync). This means caldav_uid is Fable's own UID confirmed-as-pushed, not a server-assigned value — which is fine since we generated it.
After every write to the DB:
create_event→asyncio.create_task(_push_create(event, user_id, extra_fields))update_event→asyncio.create_task(_push_update(event, user_id))delete_event→asyncio.create_task(_push_delete(caldav_uid, user_id))ifcaldav_uidis set
Each push function calls the relevant function in services/caldav.py, marks caldav_uid on success, and logs a warning on failure. CalDAV being unconfigured is treated as a no-op (not an error).
Match policy for AI update/delete tools
find_events_by_query returns all events where title ILIKE '%query%'. The calling tool applies the same match-count policy as the existing CalDAV tools: zero matches → raise ValueError("No event found matching '…'."); more than 3 matches → raise ValueError("Too many matches (N) for '…'. Be more specific. Found: …"). One or two matches use the first result.
REST API
src/fabledassistant/routes/events.py — new blueprint, registered in app.py.
| Method | Path | Description |
|---|---|---|
GET |
/api/events?from=&to= |
List events in ISO datetime range |
POST |
/api/events |
Create event |
GET |
/api/events/:id |
Get single event |
PATCH |
/api/events/:id |
Partial update |
DELETE |
/api/events/:id |
Delete event |
All routes require @login_required. Ownership enforced in the service layer (404 if event not owned by requesting user).
AI Tool Rewiring
Calendar tools in services/tools.py are updated to call services/events.py instead of services/caldav.py directly. The is_caldav_configured gate that currently hides these tools is removed — calendar tools are always available since the internal DB is always present. The tool definitions currently grouped under _CALDAV_TOOLS (appended to the tools list only when is_caldav_configured is true) must be moved into the unconditional _CORE_TOOLS list (or equivalent always-included list). The tool parameter names and LLM-facing descriptions do not change.
| Tool | Change |
|---|---|
create_event |
Calls events.create_event(...) — all existing params forwarded |
list_events |
Calls events.list_events(...) |
search_events |
Calls events.search_events(...) |
update_event |
Calls events.find_events_by_query(...), then events.update_event(...) |
delete_event |
Calls events.find_events_by_query(...), then events.delete_event(...) |
Frontend
Dependencies
@fullcalendar/vue3
@fullcalendar/daygrid # month view
@fullcalendar/timegrid # week / day view
@fullcalendar/interaction # drag-to-move, resize, click-to-create
All MIT licensed.
Components
frontend/src/views/CalendarView.vue — main view at /calendar:
- FullCalendar instance with
timeZone: 'local',initialView: 'dayGridMonth', header toolbar toggling between month and week - Loads events for the visible date range via
GET /api/events?from=&to= - Re-fetches when the visible range changes
- Click on empty date cell → opens
EventSlideOverin create mode, pre-filled with the clicked date - Click on existing event → opens
EventSlideOverin edit mode - Drag event to new date/time →
PATCH /api/events/:idwith updated times (optimistic update, revert on error) - Resize event → same
frontend/src/components/EventSlideOver.vue — reusable slide-over panel (same pattern as the workspace task slide-over):
- Fields: title (required), start date/time, end date/time, all-day toggle, location, description, colour picker, optional project selector
recurrenceis not a form field — it is stored and pushed to CalDAV via AI tools only- Create mode:
POST /api/events - Edit mode:
PATCH /api/events/:id+ delete button (DELETE /api/events/:id) - Closes on save, cancel, or Escape
frontend/src/api/client.ts — add typed helpers and EventEntry / CreateEventBody interfaces:
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;
}
listEvents(from: string, to: string): Promise<EventEntry[]>
createEvent(body: CreateEventBody): Promise<EventEntry>
getEvent(id: number): Promise<EventEntry>
updateEvent(id: number, body: Partial<CreateEventBody>): Promise<EventEntry>
deleteEvent(id: number): Promise<void>
Navigation
/calendaradded to Vue Router- Sidebar nav item added
- Keyboard shortcut:
g+l(calendar) —cis already taken by chat
Error Handling
- CalDAV push failures are logged at
WARNINGlevel and do not surface to the user - If CalDAV is unconfigured, push is skipped silently
- Drag-to-move/resize failures (PATCH error) revert the event to its previous position in FullCalendar's UI via the
revertcallback - 404 returned for event access by non-owner
Testing
tests/test_events_service.py— unit tests forcreate_event,list_events,search_events,update_event,delete_event,find_events_by_querywith mocked DB session; separate test verifying CalDAV push background task is firedtests/test_events_routes.py— route tests for all five endpoints verifying ownership enforcement and correct status codes- TypeScript check (
make typecheck) after all frontend changes
Out of Scope
- Pull sync (importing events from CalDAV into Fable) — possible future upgrade
- Recurring event expansion in the UI (RRULE stored and forwarded to CalDAV via AI tools only)
- Shared calendars / multi-user event invites
- Event notifications / reminders in the Fable push system