diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 8fb6d7d..2da9c1d 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,6 +8,11 @@
"name": "fabledassistant-frontend",
"version": "0.1.0",
"dependencies": {
+ "@fullcalendar/core": "^6.1.20",
+ "@fullcalendar/daygrid": "^6.1.20",
+ "@fullcalendar/interaction": "^6.1.20",
+ "@fullcalendar/timegrid": "^6.1.20",
+ "@fullcalendar/vue3": "^6.1.20",
"@tiptap/core": "^3.0.0",
"@tiptap/extension-link": "^3.0.0",
"@tiptap/extension-list": "^3.0.0",
@@ -564,6 +569,55 @@
"license": "MIT",
"optional": true
},
+ "node_modules/@fullcalendar/core": {
+ "version": "6.1.20",
+ "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
+ "integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
+ "license": "MIT",
+ "dependencies": {
+ "preact": "~10.12.1"
+ }
+ },
+ "node_modules/@fullcalendar/daygrid": {
+ "version": "6.1.20",
+ "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz",
+ "integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@fullcalendar/core": "~6.1.20"
+ }
+ },
+ "node_modules/@fullcalendar/interaction": {
+ "version": "6.1.20",
+ "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz",
+ "integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@fullcalendar/core": "~6.1.20"
+ }
+ },
+ "node_modules/@fullcalendar/timegrid": {
+ "version": "6.1.20",
+ "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz",
+ "integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==",
+ "license": "MIT",
+ "dependencies": {
+ "@fullcalendar/daygrid": "~6.1.20"
+ },
+ "peerDependencies": {
+ "@fullcalendar/core": "~6.1.20"
+ }
+ },
+ "node_modules/@fullcalendar/vue3": {
+ "version": "6.1.20",
+ "resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.20.tgz",
+ "integrity": "sha512-8qg6pS27II9QBwFkkJC+7SfflMpWqOe7i3ii5ODq9KpLAjwQAd/zjfq8RvKR1Yryoh5UmMCmvRbMB7i4RGtqog==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@fullcalendar/core": "~6.1.20",
+ "vue": "^3.0.11"
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2994,6 +3048,16 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/preact": {
+ "version": "10.12.1",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
+ "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
"node_modules/prosemirror-changeset": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 6dd33ae..92b847a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -9,6 +9,11 @@
"preview": "vite preview"
},
"dependencies": {
+ "@fullcalendar/core": "^6.1.20",
+ "@fullcalendar/daygrid": "^6.1.20",
+ "@fullcalendar/interaction": "^6.1.20",
+ "@fullcalendar/timegrid": "^6.1.20",
+ "@fullcalendar/vue3": "^6.1.20",
"@tiptap/core": "^3.0.0",
"@tiptap/extension-link": "^3.0.0",
"@tiptap/extension-list": "^3.0.0",
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 00db060..3412abe 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -82,6 +82,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
case "p": router.push("/projects"); break;
case "c": router.push("/chat"); break;
case "g": router.push("/graph"); break;
+ case "l": router.push("/calendar"); break;
}
return;
}
@@ -202,6 +203,12 @@ onUnmounted(() => {
c
Chat
+
+ g
+ +
+ l
+ Calendar
+
Esc
Unfocus field → go home
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index d53055e..09724a7 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -516,3 +516,69 @@ export async function apiStreamPost(
}
}
}
+
+// ---------------------------------------------------------------------------
+// Calendar events
+// ---------------------------------------------------------------------------
+
+export 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;
+ caldav_uid: string;
+ project_id: number | null;
+ user_id: number;
+ created_at: string | null;
+ updated_at: string | null;
+}
+
+export interface EventCreatePayload {
+ title: string;
+ start_dt: string;
+ end_dt?: string;
+ all_day?: boolean;
+ description?: string;
+ location?: string;
+ color?: string;
+ recurrence?: string;
+ project_id?: number;
+}
+
+export interface EventUpdatePayload {
+ title?: string;
+ start_dt?: string;
+ end_dt?: string;
+ all_day?: boolean;
+ description?: string;
+ location?: string;
+ color?: string;
+ recurrence?: string;
+ project_id?: number;
+}
+
+export async function listEvents(from: string, to: string): Promise {
+ return apiGet(`/api/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
+}
+
+export async function createEvent(payload: EventCreatePayload): Promise {
+ return apiPost('/api/events', payload);
+}
+
+export async function getEvent(id: number): Promise {
+ return apiGet(`/api/events/${id}`);
+}
+
+export async function updateEvent(id: number, payload: EventUpdatePayload): Promise {
+ return apiPatch(`/api/events/${id}`, payload);
+}
+
+export async function deleteEvent(id: number): Promise {
+ return apiDelete(`/api/events/${id}`);
+}
diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue
index 01e649c..a706590 100644
--- a/frontend/src/components/AppHeader.vue
+++ b/frontend/src/components/AppHeader.vue
@@ -77,6 +77,7 @@ router.afterEach(() => {
Tasks
Chat
Graph
+ Calendar
Briefing
Shared
@@ -126,6 +127,7 @@ router.afterEach(() => {
Tasks
Chat
Graph
+ Calendar
Briefing
Shared
diff --git a/frontend/src/components/EventSlideOver.vue b/frontend/src/components/EventSlideOver.vue
new file mode 100644
index 0000000..a04d918
--- /dev/null
+++ b/frontend/src/components/EventSlideOver.vue
@@ -0,0 +1,444 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 3c99546..ff162e7 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -110,6 +110,11 @@ const router = createRouter({
name: "shared-with-me",
component: () => import("@/views/SharedWithMeView.vue"),
},
+ {
+ path: "/calendar",
+ name: "calendar",
+ component: () => import("@/views/CalendarView.vue"),
+ },
{
path: "/briefing",
name: "briefing",
diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue
new file mode 100644
index 0000000..32f458f
--- /dev/null
+++ b/frontend/src/views/CalendarView.vue
@@ -0,0 +1,270 @@
+
+
+
+
+
+
+
diff --git a/src/fabledassistant/services/tools.py b/src/fabledassistant/services/tools.py
index 081b1f3..bb9ff56 100644
--- a/src/fabledassistant/services/tools.py
+++ b/src/fabledassistant/services/tools.py
@@ -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":