From 0e27be5b6370c1da605a9ab4d61915b66418f673 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 25 Mar 2026 11:41:14 -0400 Subject: [PATCH] docs: add internal calendar with CalDAV sync design spec Co-Authored-By: Claude Sonnet 4.6 --- .../2026-03-25-internal-calendar-design.md | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 docs/specs/2026-03-25-internal-calendar-design.md diff --git a/docs/specs/2026-03-25-internal-calendar-design.md b/docs/specs/2026-03-25-internal-calendar-design.md new file mode 100644 index 0000000..8327021 --- /dev/null +++ b/docs/specs/2026-03-25-internal-calendar-design.md @@ -0,0 +1,187 @@ +# 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_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))` 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: +```typescript +listEvents(from: string, to: string): Promise +createEvent(body: CreateEventBody): Promise +getEvent(id: number): Promise +updateEvent(id: number, body: Partial): Promise +deleteEvent(id: number): Promise +``` + +### Navigation + +- `/calendar` added to Vue Router +- Sidebar nav item added +- Keyboard shortcut: `g` + `l` (ca**l**endar) — `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