From 651bc1ba7b6618216b2b62116182fb7c1cf2c87e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 25 Mar 2026 11:47:52 -0400 Subject: [PATCH] docs: revise internal calendar spec based on reviewer feedback Co-Authored-By: Claude Sonnet 4.6 --- .../2026-03-25-internal-calendar-design.md | 81 ++++++++++++------- 1 file changed, 54 insertions(+), 27 deletions(-) diff --git a/docs/specs/2026-03-25-internal-calendar-design.md b/docs/specs/2026-03-25-internal-calendar-design.md index 8327021..1f6d87f 100644 --- a/docs/specs/2026-03-25-internal-calendar-design.md +++ b/docs/specs/2026-03-25-internal-calendar-design.md @@ -6,7 +6,7 @@ Add an internal event store to Fable's database, a full calendar UI (month/week ## 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. +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. @@ -30,15 +30,15 @@ Fable's `events` DB table is the single source of truth. CalDAV sync is a best-e ## Data Model -### `events` table (existing, extend via migration) +### `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, used in CalDAV push | -| `caldav_uid` | `TEXT DEFAULT ''` | **New.** UID confirmed by CalDAV server after successful push; empty if never synced | +| `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 | @@ -46,11 +46,18 @@ Fable's `events` DB table is the single source of truth. CalDAV sync is a best-e | `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`) | +| `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, consistent with the project's migration convention. +**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`. --- @@ -61,25 +68,36 @@ Fable's `events` DB table is the single source of truth. CalDAV sync is a best-e ### Public functions ``` -create_event(user_id, title, start_dt, end_dt, all_day, description, location, color, recurrence, project_id) → Event +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] ``` -### CalDAV push +`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))` +- `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`, writes `caldav_uid` back on success, and logs a warning on failure. CalDAV being unconfigured is treated as a no-op (not an error). +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). -The existing `services/caldav.py` is **not modified** — it remains a dumb push target. +### 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. --- @@ -101,17 +119,15 @@ All routes require `@login_required`. Ownership enforced in the service layer (4 ## AI Tool Rewiring -The following tools in `services/tools.py` are updated to call `services/events.py` instead of `services/caldav.py` directly: +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(...)` | +| `create_event` | Calls `events.create_event(...)` — all existing params forwarded | | `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. +| `update_event` | Calls `events.find_events_by_query(...)`, then `events.update_event(...)` | +| `delete_event` | Calls `events.find_events_by_query(...)`, then `events.delete_event(...)` | --- @@ -134,19 +150,30 @@ All MIT licensed. - 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) +- 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, start date/time, end date/time, all-day toggle, location, description, colour picker, project selector +- 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: +**`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 createEvent(body: CreateEventBody): Promise getEvent(id: number): Promise @@ -166,22 +193,22 @@ deleteEvent(id: number): Promise - 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 +- 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` 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 +- `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 Option B upgrade -- Recurring event expansion in the UI (RRULE stored and pushed to CalDAV but not expanded client-side) +- 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