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

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_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 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_eventasyncio.create_task(_push_create(event, user_id, extra_fields))
  • 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, 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 EventSlideOver in create mode, pre-filled with the clicked date
  • Click on existing event → opens EventSlideOver in edit mode
  • Drag event to new date/time → PATCH /api/events/:id with 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
  • recurrence is 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

  • /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/resize failures (PATCH error) revert the event to its previous position in FullCalendar's UI via the revert callback
  • 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, find_events_by_query with mocked DB session; separate test verifying CalDAV push background task is fired
  • tests/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