651bc1ba7b
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
215 lines
11 KiB
Markdown
215 lines
11 KiB
Markdown
# 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:
|
|
```python
|
|
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))` 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:
|
|
|
|
```typescript
|
|
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` (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/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
|