Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
8.1 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 models/event.py and events DB table exist as dead code from an abandoned Radicale integration.
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, extend via migration)
| 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, used in CalDAV push |
caldav_uid |
TEXT DEFAULT '' |
New. UID confirmed by CalDAV server after successful push; 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 (e.g. FREQ=WEEKLY;BYDAY=MO) |
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, consistent with the project's migration convention.
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) → 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
CalDAV push
After every write to the DB:
create_event→asyncio.create_task(_push_create(event, user_id))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, writes caldav_uid back on success, and logs a warning on failure. CalDAV being unconfigured is treated as a no-op (not an error).
The existing services/caldav.py is not modified — it remains a dumb push target.
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
The following tools in services/tools.py are updated to call services/events.py instead of services/caldav.py directly:
| Tool | Change |
|---|---|
create_event |
Calls events.create_event(...) |
list_events |
Calls events.list_events(...) |
search_events |
Calls events.search_events(...) |
update_event |
Queries internal DB by title ILIKE, then calls events.update_event(...) |
delete_event |
Queries internal DB by title ILIKE, then calls events.delete_event(...) |
The tool interface (parameter names, LLM-facing descriptions) does not change. Users with an existing CalDAV connection see no behaviour change.
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 cell → opens
EventSlideOverin create mode - Click on event → opens
EventSlideOverin edit mode - Drag event →
PATCH /api/events/:idwith new 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, start date/time, end date/time, all-day toggle, location, description, colour picker, project selector
- 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:
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 failures (PATCH error) revert the event to its previous position in the UI
- 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_eventwith mocked DB session; separate test verifying CalDAV push is fired as a background tasktests/test_events_routes.py— route tests for all five endpoints (create, list, get, update, delete) verifying ownership enforcement- TypeScript check (
make typecheck) after all frontend changes
Out of Scope
- Pull sync (importing events from CalDAV into Fable) — possible future Option B upgrade
- Recurring event expansion in the UI (RRULE stored and pushed to CalDAV but not expanded client-side)
- Shared calendars / multi-user event invites
- Event notifications / reminders in the Fable push system