feat(calendar): internal event store with FullCalendar UI and CalDAV push sync

- AI calendar tools now always available (moved from _CALDAV_TOOLS to _CORE_TOOLS);
  create/list/search/update/delete events go through the internal DB store first,
  with fire-and-forget CalDAV push sync when the user has CalDAV configured
- Add EventEntry interface and typed API helpers (listEvents, createEvent,
  getEvent, updateEvent, deleteEvent) to client.ts
- Install @fullcalendar/vue3, daygrid, timegrid, interaction, core packages
- Add EventSlideOver.vue: create/edit/delete slide-over with title, start/end,
  all-day toggle, location, description, color picker, and project selector
- Add CalendarView.vue: month/week/day FullCalendar with drag-drop and resize
  wired to PATCH /api/events/:id; click empty date opens create slide-over
- Wire /calendar route, Calendar nav link in AppHeader, g+l keyboard shortcut

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 13:38:09 -04:00
parent 8d330afc6d
commit c3e201d26a
9 changed files with 970 additions and 52 deletions
+107 -52
View File
@@ -7,13 +7,16 @@ from datetime import date, datetime
from difflib import SequenceMatcher
from fabledassistant.services.caldav import (
create_event,
delete_event,
is_caldav_configured,
list_calendars,
list_events,
search_events,
update_event,
)
from fabledassistant.services.events import (
create_event as events_create_event,
list_events as events_list_events,
search_events as events_search_events,
update_event as events_update_event,
delete_event as events_delete_event,
find_events_by_query,
)
from fabledassistant.config import Config
from fabledassistant.services.notes import create_note, delete_note, get_note_by_title, list_notes, update_note
@@ -573,10 +576,6 @@ _CORE_TOOLS = [
},
},
},
]
# CalDAV tools — only included when user has CalDAV configured
_CALDAV_TOOLS = [
{
"type": "function",
"function": {
@@ -609,6 +608,10 @@ _CALDAV_TOOLS = [
"type": "string",
"description": "Optional event location",
},
"color": {
"type": "string",
"description": "Optional hex color for the event (e.g. '#6366f1')",
},
"all_day": {
"type": "boolean",
"description": "Set to true for all-day events like birthdays, holidays, deadlines (default false)",
@@ -626,14 +629,14 @@ _CALDAV_TOOLS = [
"items": {"type": "string"},
"description": "Optional list of attendee email addresses",
},
"timezone": {
"type": "string",
"description": "Optional IANA timezone (e.g. 'America/New_York'). Falls back to user's caldav_timezone setting.",
},
"calendar_name": {
"type": "string",
"description": "Optional calendar name to create the event in. Falls back to default calendar.",
},
"project": {
"type": "string",
"description": "Optional project name to associate this event with",
},
},
"required": ["title", "start"],
},
@@ -701,6 +704,10 @@ _CALDAV_TOOLS = [
"type": "string",
"description": "New end datetime in ISO 8601 format",
},
"all_day": {
"type": "boolean",
"description": "Whether the event is all-day",
},
"description": {
"type": "string",
"description": "New event description",
@@ -709,13 +716,13 @@ _CALDAV_TOOLS = [
"type": "string",
"description": "New event location",
},
"timezone": {
"color": {
"type": "string",
"description": "Optional IANA timezone (e.g. 'America/New_York')",
"description": "New hex color for the event (e.g. '#6366f1')",
},
"calendar_name": {
"recurrence": {
"type": "string",
"description": "Optional calendar name to search in",
"description": "New iCalendar RRULE",
},
},
"required": ["query"],
@@ -734,15 +741,15 @@ _CALDAV_TOOLS = [
"type": "string",
"description": "Search term to find the event to delete (matches against title)",
},
"calendar_name": {
"type": "string",
"description": "Optional calendar name to search in",
},
},
"required": ["query"],
},
},
},
]
# CalDAV tools — only included when user has CalDAV configured
_CALDAV_TOOLS = [
{
"type": "function",
"function": {
@@ -1223,17 +1230,45 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
}
elif tool_name == "create_event":
result = await create_event(
start_str = arguments["start"]
end_str = arguments.get("end")
all_day = arguments.get("all_day", False)
# Parse start — accept date-only (YYYY-MM-DD) or full ISO datetime
try:
if "T" in start_str or " " in start_str:
start_dt = datetime.fromisoformat(start_str)
else:
start_dt = datetime.fromisoformat(f"{start_str}T00:00:00")
all_day = True
except (ValueError, TypeError):
return {"success": False, "error": f"Invalid start datetime: {start_str!r}"}
end_dt = None
if end_str:
try:
if "T" in end_str or " " in end_str:
end_dt = datetime.fromisoformat(end_str)
else:
end_dt = datetime.fromisoformat(f"{end_str}T00:00:00")
except (ValueError, TypeError):
return {"success": False, "error": f"Invalid end datetime: {end_str!r}"}
project_id = None
project_name = arguments.get("project")
if project_name:
proj = await _resolve_project(user_id, project_name)
if proj:
project_id = proj.id
event = await events_create_event(
user_id=user_id,
title=arguments.get("title", "Untitled Event"),
start=arguments["start"],
end=arguments.get("end"),
duration=arguments.get("duration"),
description=arguments.get("description"),
location=arguments.get("location"),
all_day=arguments.get("all_day", False),
start_dt=start_dt,
end_dt=end_dt,
all_day=all_day,
description=arguments.get("description") or "",
location=arguments.get("location") or "",
color=arguments.get("color") or "",
recurrence=arguments.get("recurrence"),
timezone=arguments.get("timezone"),
project_id=project_id,
duration=arguments.get("duration"),
reminder_minutes=arguments.get("reminder_minutes"),
attendees=arguments.get("attendees"),
calendar_name=arguments.get("calendar_name"),
@@ -1241,26 +1276,31 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
return {
"success": True,
"type": "event",
"data": result,
"data": event.to_dict(),
}
elif tool_name == "list_events":
events = await list_events(
try:
date_from = datetime.fromisoformat(arguments["date_from"])
date_to = datetime.fromisoformat(arguments["date_to"])
except (ValueError, TypeError, KeyError) as exc:
return {"success": False, "error": f"Invalid date range: {exc}"}
events = await events_list_events(
user_id=user_id,
date_from=arguments["date_from"],
date_to=arguments["date_to"],
date_from=date_from,
date_to=date_to,
)
return {
"success": True,
"type": "events",
"data": {
"count": len(events),
"events": events,
"events": [e.to_dict() for e in events],
},
}
elif tool_name == "search_events":
events = await search_events(
events = await events_search_events(
user_id=user_id,
query=arguments.get("query", ""),
)
@@ -1270,38 +1310,53 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
"data": {
"query": arguments.get("query", ""),
"count": len(events),
"events": events,
"events": [e.to_dict() for e in events],
},
}
elif tool_name == "update_event":
result = await update_event(
query = arguments.get("query", "")
matches = await find_events_by_query(user_id=user_id, query=query)
if not matches:
return {"success": False, "error": f"No event found matching '{query}'."}
event_to_update = matches[0]
fields: dict = {}
for str_field in ("title", "description", "location", "color", "recurrence"):
if arguments.get(str_field) is not None:
fields[str_field] = arguments[str_field]
if arguments.get("all_day") is not None:
fields["all_day"] = arguments["all_day"]
for dt_field, key in (("start_dt", "start"), ("end_dt", "end")):
val = arguments.get(key)
if val:
try:
fields[dt_field] = datetime.fromisoformat(val)
except (ValueError, TypeError):
return {"success": False, "error": f"Invalid datetime for {key}: {val!r}"}
updated = await events_update_event(
user_id=user_id,
query=arguments["query"],
title=arguments.get("title"),
start=arguments.get("start"),
end=arguments.get("end"),
description=arguments.get("description"),
location=arguments.get("location"),
timezone=arguments.get("timezone"),
calendar_name=arguments.get("calendar_name"),
event_id=event_to_update.id,
**fields,
)
if updated is None:
return {"success": False, "error": "Event not found or update failed."}
return {
"success": True,
"type": "event_updated",
"data": result,
"data": updated.to_dict(),
}
elif tool_name == "delete_event":
result = await delete_event(
user_id=user_id,
query=arguments["query"],
calendar_name=arguments.get("calendar_name"),
)
query = arguments.get("query", "")
matches = await find_events_by_query(user_id=user_id, query=query)
if not matches:
return {"success": False, "error": f"No event found matching '{query}'."}
event_to_delete = matches[0]
await events_delete_event(user_id=user_id, event_id=event_to_delete.id)
return {
"success": True,
"type": "event_deleted",
"data": result,
"data": {"id": event_to_delete.id, "title": event_to_delete.title},
}
elif tool_name == "list_calendars":