Files
FabledScribe/docs/specs/2026-03-25-internal-calendar-design.md
T
2026-03-25 11:41:14 -04:00

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_dt stored 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_timezone setting for iCal DTSTART/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_eventasyncio.create_task(_push_create(event, user_id))
  • update_eventasyncio.create_task(_push_update(event, user_id))
  • delete_eventasyncio.create_task(_push_delete(caldav_uid, user_id)) if caldav_uid is 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 EventSlideOver in create mode
  • Click on event → opens EventSlideOver in edit mode
  • Drag event → PATCH /api/events/:id with 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

  • /calendar added to Vue Router
  • Sidebar nav item added
  • Keyboard shortcut: g + l (calendar) — c is already taken by chat

Error Handling

  • CalDAV push failures are logged at WARNING level 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 for create_event, list_events, search_events, update_event, delete_event with mocked DB session; separate test verifying CalDAV push is fired as a background task
  • tests/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