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:
Generated
+64
@@ -8,6 +8,11 @@
|
|||||||
"name": "fabledassistant-frontend",
|
"name": "fabledassistant-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"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/core": "^3.0.0",
|
||||||
"@tiptap/extension-link": "^3.0.0",
|
"@tiptap/extension-link": "^3.0.0",
|
||||||
"@tiptap/extension-list": "^3.0.0",
|
"@tiptap/extension-list": "^3.0.0",
|
||||||
@@ -564,6 +569,55 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"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": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@@ -2994,6 +3048,16 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/prosemirror-changeset": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/core": "^3.0.0",
|
||||||
"@tiptap/extension-link": "^3.0.0",
|
"@tiptap/extension-link": "^3.0.0",
|
||||||
"@tiptap/extension-list": "^3.0.0",
|
"@tiptap/extension-list": "^3.0.0",
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
|||||||
case "p": router.push("/projects"); break;
|
case "p": router.push("/projects"); break;
|
||||||
case "c": router.push("/chat"); break;
|
case "c": router.push("/chat"); break;
|
||||||
case "g": router.push("/graph"); break;
|
case "g": router.push("/graph"); break;
|
||||||
|
case "l": router.push("/calendar"); break;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -202,6 +203,12 @@ onUnmounted(() => {
|
|||||||
<kbd class="shortcut-key">c</kbd>
|
<kbd class="shortcut-key">c</kbd>
|
||||||
<span class="shortcut-desc">Chat</span>
|
<span class="shortcut-desc">Chat</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="shortcut-row">
|
||||||
|
<kbd class="shortcut-key">g</kbd>
|
||||||
|
<span class="shortcut-key-sep">+</span>
|
||||||
|
<kbd class="shortcut-key">l</kbd>
|
||||||
|
<span class="shortcut-desc">Calendar</span>
|
||||||
|
</div>
|
||||||
<div class="shortcut-row">
|
<div class="shortcut-row">
|
||||||
<kbd class="shortcut-key">Esc</kbd>
|
<kbd class="shortcut-key">Esc</kbd>
|
||||||
<span class="shortcut-desc">Unfocus field → go home</span>
|
<span class="shortcut-desc">Unfocus field → go home</span>
|
||||||
|
|||||||
@@ -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<EventEntry[]> {
|
||||||
|
return apiGet<EventEntry[]>(`/api/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEvent(payload: EventCreatePayload): Promise<EventEntry> {
|
||||||
|
return apiPost<EventEntry>('/api/events', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEvent(id: number): Promise<EventEntry> {
|
||||||
|
return apiGet<EventEntry>(`/api/events/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEvent(id: number, payload: EventUpdatePayload): Promise<EventEntry> {
|
||||||
|
return apiPatch<EventEntry>(`/api/events/${id}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEvent(id: number): Promise<void> {
|
||||||
|
return apiDelete(`/api/events/${id}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ router.afterEach(() => {
|
|||||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||||
<router-link to="/graph" class="nav-link">Graph</router-link>
|
<router-link to="/graph" class="nav-link">Graph</router-link>
|
||||||
|
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,6 +127,7 @@ router.afterEach(() => {
|
|||||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||||
<router-link to="/graph" class="nav-link">Graph</router-link>
|
<router-link to="/graph" class="nav-link">Graph</router-link>
|
||||||
|
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||||
<div class="mobile-divider"></div>
|
<div class="mobile-divider"></div>
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||||
|
import { createEvent, updateEvent, deleteEvent, type EventEntry, type EventCreatePayload, type EventUpdatePayload } from "@/api/client";
|
||||||
|
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||||
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
// null = create mode; EventEntry = edit mode
|
||||||
|
event: EventEntry | null;
|
||||||
|
// pre-filled date string for create mode (YYYY-MM-DD or ISO)
|
||||||
|
initialDate?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "close"): void;
|
||||||
|
(e: "created", event: EventEntry): void;
|
||||||
|
(e: "updated", event: EventEntry): void;
|
||||||
|
(e: "deleted", id: number): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const toast = useToastStore();
|
||||||
|
|
||||||
|
const isEditMode = computed(() => !!props.event);
|
||||||
|
const saving = ref(false);
|
||||||
|
const deleting = ref(false);
|
||||||
|
const deleteConfirm = ref(false);
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
const title = ref("");
|
||||||
|
const startDate = ref("");
|
||||||
|
const startTime = ref("");
|
||||||
|
const endDate = ref("");
|
||||||
|
const endTime = ref("");
|
||||||
|
const allDay = ref(false);
|
||||||
|
const description = ref("");
|
||||||
|
const location = ref("");
|
||||||
|
const color = ref("");
|
||||||
|
const projectId = ref<number | null>(null);
|
||||||
|
|
||||||
|
function dateFromIso(iso: string): string {
|
||||||
|
return iso.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeFromIso(iso: string): string {
|
||||||
|
if (!iso.includes("T")) return "09:00";
|
||||||
|
return iso.slice(11, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIso(date: string, time: string): string {
|
||||||
|
if (!time) return `${date}T00:00:00`;
|
||||||
|
return `${date}T${time}:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
if (props.event) {
|
||||||
|
title.value = props.event.title;
|
||||||
|
allDay.value = props.event.all_day;
|
||||||
|
startDate.value = dateFromIso(props.event.start_dt);
|
||||||
|
startTime.value = props.event.all_day ? "" : timeFromIso(props.event.start_dt);
|
||||||
|
endDate.value = props.event.end_dt ? dateFromIso(props.event.end_dt) : "";
|
||||||
|
endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : "";
|
||||||
|
description.value = props.event.description || "";
|
||||||
|
location.value = props.event.location || "";
|
||||||
|
color.value = props.event.color || "";
|
||||||
|
projectId.value = props.event.project_id;
|
||||||
|
} else {
|
||||||
|
title.value = "";
|
||||||
|
allDay.value = false;
|
||||||
|
const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10);
|
||||||
|
startDate.value = base;
|
||||||
|
startTime.value = "09:00";
|
||||||
|
endDate.value = base;
|
||||||
|
endTime.value = "10:00";
|
||||||
|
description.value = "";
|
||||||
|
location.value = "";
|
||||||
|
color.value = "";
|
||||||
|
projectId.value = null;
|
||||||
|
}
|
||||||
|
deleteConfirm.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.event, resetForm, { immediate: true });
|
||||||
|
watch(() => props.initialDate, resetForm);
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") emit("close");
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener("keydown", handleKeydown));
|
||||||
|
onUnmounted(() => document.removeEventListener("keydown", handleKeydown));
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!title.value.trim()) {
|
||||||
|
toast.show("Title is required", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!startDate.value) {
|
||||||
|
toast.show("Start date is required", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value);
|
||||||
|
const end_dt = endDate.value
|
||||||
|
? (allDay.value ? `${endDate.value}T00:00:00` : toIso(endDate.value, endTime.value))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
if (isEditMode.value && props.event) {
|
||||||
|
const payload: EventUpdatePayload = {
|
||||||
|
title: title.value.trim(),
|
||||||
|
start_dt,
|
||||||
|
end_dt,
|
||||||
|
all_day: allDay.value,
|
||||||
|
description: description.value,
|
||||||
|
location: location.value,
|
||||||
|
color: color.value,
|
||||||
|
project_id: projectId.value ?? undefined,
|
||||||
|
};
|
||||||
|
const updated = await updateEvent(props.event.id, payload);
|
||||||
|
toast.show("Event updated", "success");
|
||||||
|
emit("updated", updated);
|
||||||
|
} else {
|
||||||
|
const payload: EventCreatePayload = {
|
||||||
|
title: title.value.trim(),
|
||||||
|
start_dt,
|
||||||
|
end_dt,
|
||||||
|
all_day: allDay.value,
|
||||||
|
description: description.value,
|
||||||
|
location: location.value,
|
||||||
|
color: color.value,
|
||||||
|
project_id: projectId.value ?? undefined,
|
||||||
|
};
|
||||||
|
const created = await createEvent(payload);
|
||||||
|
toast.show("Event created", "success");
|
||||||
|
emit("created", created);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.show("Failed to save event", "error");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDelete() {
|
||||||
|
if (!props.event) return;
|
||||||
|
deleting.value = true;
|
||||||
|
try {
|
||||||
|
await deleteEvent(props.event.id);
|
||||||
|
toast.show("Event deleted", "success");
|
||||||
|
emit("deleted", props.event.id);
|
||||||
|
} catch {
|
||||||
|
toast.show("Failed to delete event", "error");
|
||||||
|
deleting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="slide-over-backdrop" @click.self="emit('close')">
|
||||||
|
<div class="slide-over-panel" role="dialog" aria-modal="true">
|
||||||
|
<div class="so-header">
|
||||||
|
<h2 class="so-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
|
||||||
|
<button class="so-close" @click="emit('close')" aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="so-form" @submit.prevent="save">
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="so-field">
|
||||||
|
<label class="so-label">Title <span class="required">*</span></label>
|
||||||
|
<input v-model="title" class="so-input" placeholder="Event title" autofocus />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- All-day toggle -->
|
||||||
|
<div class="so-field so-field-row">
|
||||||
|
<label class="so-label so-label-inline">All day</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="['toggle-btn', { active: allDay }]"
|
||||||
|
@click="allDay = !allDay"
|
||||||
|
>{{ allDay ? "Yes" : "No" }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Start -->
|
||||||
|
<div class="so-field">
|
||||||
|
<label class="so-label">Start</label>
|
||||||
|
<div class="dt-row">
|
||||||
|
<input v-model="startDate" type="date" class="so-input dt-date" />
|
||||||
|
<input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- End -->
|
||||||
|
<div class="so-field">
|
||||||
|
<label class="so-label">End <span class="so-hint">(optional)</span></label>
|
||||||
|
<div class="dt-row">
|
||||||
|
<input v-model="endDate" type="date" class="so-input dt-date" />
|
||||||
|
<input v-if="!allDay" v-model="endTime" type="time" class="so-input dt-time" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location -->
|
||||||
|
<div class="so-field">
|
||||||
|
<label class="so-label">Location <span class="so-hint">(optional)</span></label>
|
||||||
|
<input v-model="location" class="so-input" placeholder="Location" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="so-field">
|
||||||
|
<label class="so-label">Description <span class="so-hint">(optional)</span></label>
|
||||||
|
<textarea v-model="description" class="so-input so-textarea" placeholder="Description" rows="3" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Color -->
|
||||||
|
<div class="so-field so-field-row">
|
||||||
|
<label class="so-label so-label-inline">Color</label>
|
||||||
|
<div class="color-row">
|
||||||
|
<input v-model="color" type="color" class="color-picker" title="Pick event color" />
|
||||||
|
<input v-model="color" class="so-input color-hex" placeholder="#6366f1" />
|
||||||
|
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project -->
|
||||||
|
<div class="so-field">
|
||||||
|
<label class="so-label">Project <span class="so-hint">(optional)</span></label>
|
||||||
|
<ProjectSelector v-model="projectId" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="so-actions">
|
||||||
|
<button type="submit" class="btn-primary" :disabled="saving">
|
||||||
|
{{ saving ? "Saving…" : (isEditMode ? "Save" : "Create") }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary" @click="emit('close')">Cancel</button>
|
||||||
|
<template v-if="isEditMode">
|
||||||
|
<button
|
||||||
|
v-if="!deleteConfirm"
|
||||||
|
type="button"
|
||||||
|
class="btn-danger-ghost"
|
||||||
|
@click="deleteConfirm = true"
|
||||||
|
>Delete</button>
|
||||||
|
<template v-else>
|
||||||
|
<span class="delete-confirm-label">Delete this event?</span>
|
||||||
|
<button type="button" class="btn-danger" :disabled="deleting" @click="doDelete">
|
||||||
|
{{ deleting ? "Deleting…" : "Yes, delete" }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary" @click="deleteConfirm = false">No</button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.slide-over-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-over-panel {
|
||||||
|
background: var(--color-surface, #1a1b1e);
|
||||||
|
border-left: 1px solid var(--color-border, #2a2b30);
|
||||||
|
width: min(440px, 100vw);
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.so-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-border, #2a2b30);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--color-surface, #1a1b1e);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.so-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text, #e8e9f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.so-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.so-close:hover { background: var(--color-hover, rgba(255,255,255,0.06)); }
|
||||||
|
|
||||||
|
.so-form {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.1rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.so-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
|
.so-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
|
||||||
|
|
||||||
|
.so-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.so-label-inline { flex-shrink: 0; margin: 0; }
|
||||||
|
.so-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
|
||||||
|
|
||||||
|
.required { color: #f87171; }
|
||||||
|
|
||||||
|
.so-input {
|
||||||
|
background: var(--color-input-bg, #111113);
|
||||||
|
border: 1px solid var(--color-border, #2a2b30);
|
||||||
|
color: var(--color-text, #e8e9f0);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.so-input:focus { outline: none; border-color: var(--color-primary, #6366f1); }
|
||||||
|
|
||||||
|
.so-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
|
||||||
|
|
||||||
|
.dt-row { display: flex; gap: 0.5rem; }
|
||||||
|
.dt-date { flex: 1; }
|
||||||
|
.dt-time { width: 7.5rem; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
background: var(--color-input-bg, #111113);
|
||||||
|
border: 1px solid var(--color-border, #2a2b30);
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.3rem 0.9rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.toggle-btn.active {
|
||||||
|
background: var(--color-primary, #6366f1);
|
||||||
|
border-color: var(--color-primary, #6366f1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-row { display: flex; align-items: center; gap: 0.5rem; flex: 1; }
|
||||||
|
.color-picker { width: 2.4rem; height: 2.2rem; border: none; padding: 0; border-radius: 4px; cursor: pointer; flex-shrink: 0; }
|
||||||
|
.color-hex { flex: 1; }
|
||||||
|
.btn-clear-color {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.2rem 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.so-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--color-border, #2a2b30);
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.55rem 1.2rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-primary:hover:not(:disabled) { opacity: 0.88; }
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--color-input-bg, #111113);
|
||||||
|
border: 1px solid var(--color-border, #2a2b30);
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background: var(--color-hover, rgba(255,255,255,0.06)); }
|
||||||
|
|
||||||
|
.btn-danger-ghost {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
color: #ef4444;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-danger-ghost:hover { background: rgba(239, 68, 68, 0.1); }
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.delete-confirm-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -110,6 +110,11 @@ const router = createRouter({
|
|||||||
name: "shared-with-me",
|
name: "shared-with-me",
|
||||||
component: () => import("@/views/SharedWithMeView.vue"),
|
component: () => import("@/views/SharedWithMeView.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/calendar",
|
||||||
|
name: "calendar",
|
||||||
|
component: () => import("@/views/CalendarView.vue"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/briefing",
|
path: "/briefing",
|
||||||
name: "briefing",
|
name: "briefing",
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import FullCalendar from "@fullcalendar/vue3";
|
||||||
|
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||||
|
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||||
|
import interactionPlugin from "@fullcalendar/interaction";
|
||||||
|
import type { CalendarOptions, EventClickArg, EventDropArg } from "@fullcalendar/core";
|
||||||
|
import type { DateClickArg, EventResizeDoneArg } from "@fullcalendar/interaction";
|
||||||
|
import { listEvents, updateEvent, type EventEntry } from "@/api/client";
|
||||||
|
import EventSlideOver from "@/components/EventSlideOver.vue";
|
||||||
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
|
||||||
|
const toast = useToastStore();
|
||||||
|
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
||||||
|
|
||||||
|
// Slide-over state
|
||||||
|
const slideOverEvent = ref<EventEntry | null>(null); // null = create mode
|
||||||
|
const slideOverOpen = ref(false);
|
||||||
|
const slideOverDate = ref<string>("");
|
||||||
|
|
||||||
|
function openCreate(date: string) {
|
||||||
|
slideOverEvent.value = null;
|
||||||
|
slideOverDate.value = date;
|
||||||
|
slideOverOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(event: EventEntry) {
|
||||||
|
slideOverEvent.value = event;
|
||||||
|
slideOverDate.value = "";
|
||||||
|
slideOverOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSlideOver() {
|
||||||
|
slideOverOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event entry cache keyed by id for quick lookups when clicking FC events
|
||||||
|
const eventCache = new Map<number, EventEntry>();
|
||||||
|
|
||||||
|
function toFcEvent(e: EventEntry) {
|
||||||
|
return {
|
||||||
|
id: String(e.id),
|
||||||
|
title: e.title,
|
||||||
|
start: e.start_dt,
|
||||||
|
end: e.end_dt ?? undefined,
|
||||||
|
allDay: e.all_day,
|
||||||
|
backgroundColor: e.color || undefined,
|
||||||
|
borderColor: e.color || undefined,
|
||||||
|
extendedProps: { entryId: e.id },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEvents(
|
||||||
|
fetchInfo: { startStr: string; endStr: string },
|
||||||
|
successCallback: (events: object[]) => void,
|
||||||
|
failureCallback: (error: Error) => void,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const entries = await listEvents(fetchInfo.startStr, fetchInfo.endStr);
|
||||||
|
eventCache.clear();
|
||||||
|
for (const e of entries) eventCache.set(e.id, e);
|
||||||
|
successCallback(entries.map(toFcEvent));
|
||||||
|
} catch (err) {
|
||||||
|
failureCallback(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDateClick(arg: DateClickArg) {
|
||||||
|
openCreate(arg.dateStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEventClick(arg: EventClickArg) {
|
||||||
|
const id = arg.event.extendedProps.entryId as number;
|
||||||
|
const entry = eventCache.get(id);
|
||||||
|
if (entry) openEdit(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEventDrop(arg: EventDropArg) {
|
||||||
|
const id = arg.event.extendedProps.entryId as number;
|
||||||
|
const start_dt = arg.event.startStr;
|
||||||
|
const end_dt = arg.event.endStr || undefined;
|
||||||
|
try {
|
||||||
|
const updated = await updateEvent(id, { start_dt, end_dt, all_day: arg.event.allDay });
|
||||||
|
eventCache.set(id, updated);
|
||||||
|
} catch {
|
||||||
|
arg.revert();
|
||||||
|
toast.show("Failed to move event", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEventResize(arg: EventResizeDoneArg) {
|
||||||
|
const id = arg.event.extendedProps.entryId as number;
|
||||||
|
const start_dt = arg.event.startStr;
|
||||||
|
const end_dt = arg.event.endStr || undefined;
|
||||||
|
try {
|
||||||
|
const updated = await updateEvent(id, { start_dt, end_dt });
|
||||||
|
eventCache.set(id, updated);
|
||||||
|
} catch {
|
||||||
|
arg.revert();
|
||||||
|
toast.show("Failed to resize event", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCreated(entry: EventEntry) {
|
||||||
|
eventCache.set(entry.id, entry);
|
||||||
|
calendarRef.value?.getApi().addEvent(toFcEvent(entry));
|
||||||
|
closeSlideOver();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUpdated(entry: EventEntry) {
|
||||||
|
eventCache.set(entry.id, entry);
|
||||||
|
// Replace the event in FullCalendar
|
||||||
|
const api = calendarRef.value?.getApi();
|
||||||
|
if (api) {
|
||||||
|
const existing = api.getEventById(String(entry.id));
|
||||||
|
if (existing) {
|
||||||
|
existing.remove();
|
||||||
|
api.addEvent(toFcEvent(entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeSlideOver();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleted(id: number) {
|
||||||
|
eventCache.delete(id);
|
||||||
|
const api = calendarRef.value?.getApi();
|
||||||
|
if (api) {
|
||||||
|
api.getEventById(String(id))?.remove();
|
||||||
|
}
|
||||||
|
closeSlideOver();
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarOptions: CalendarOptions = {
|
||||||
|
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||||||
|
initialView: "dayGridMonth",
|
||||||
|
timeZone: "local",
|
||||||
|
editable: true,
|
||||||
|
selectable: false,
|
||||||
|
headerToolbar: {
|
||||||
|
left: "prev,next today",
|
||||||
|
center: "title",
|
||||||
|
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
||||||
|
},
|
||||||
|
events: loadEvents,
|
||||||
|
dateClick: handleDateClick,
|
||||||
|
eventClick: handleEventClick,
|
||||||
|
eventDrop: handleEventDrop,
|
||||||
|
eventResize: handleEventResize,
|
||||||
|
height: "auto",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="calendar-view">
|
||||||
|
<div class="cal-header">
|
||||||
|
<h1 class="cal-title">Calendar</h1>
|
||||||
|
<button class="btn-new-event" @click="openCreate(new Date().toISOString().slice(0, 10))">
|
||||||
|
+ New Event
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fc-wrapper">
|
||||||
|
<FullCalendar ref="calendarRef" :options="calendarOptions" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EventSlideOver
|
||||||
|
v-if="slideOverOpen"
|
||||||
|
:event="slideOverEvent"
|
||||||
|
:initial-date="slideOverDate"
|
||||||
|
@close="closeSlideOver"
|
||||||
|
@created="onCreated"
|
||||||
|
@updated="onUpdated"
|
||||||
|
@deleted="onDeleted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.calendar-view {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1.5rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text, #e8e9f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new-event {
|
||||||
|
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.55rem 1.1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn-new-event:hover { opacity: 0.88; }
|
||||||
|
|
||||||
|
.fc-wrapper {
|
||||||
|
background: var(--color-surface, #1a1b1e);
|
||||||
|
border: 1px solid var(--color-border, #2a2b30);
|
||||||
|
border-radius: var(--radius-lg, 18px);
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FullCalendar dark theme overrides */
|
||||||
|
:deep(.fc) {
|
||||||
|
color: var(--color-text, #e8e9f0);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
:deep(.fc-toolbar-title) {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
:deep(.fc-button) {
|
||||||
|
background: var(--color-input-bg, #111113);
|
||||||
|
border: 1px solid var(--color-border, #2a2b30);
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
:deep(.fc-button:hover),
|
||||||
|
:deep(.fc-button-active) {
|
||||||
|
background: var(--color-primary, #6366f1) !important;
|
||||||
|
border-color: var(--color-primary, #6366f1) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
:deep(.fc-button:focus) { box-shadow: none !important; }
|
||||||
|
:deep(.fc-daygrid-day-number),
|
||||||
|
:deep(.fc-col-header-cell-cushion) {
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
:deep(.fc-daygrid-day.fc-day-today) {
|
||||||
|
background: rgba(99, 102, 241, 0.08);
|
||||||
|
}
|
||||||
|
:deep(.fc-event) {
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
padding: 1px 4px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
:deep(.fc-event-main) { color: #fff; }
|
||||||
|
:deep(.fc-event:not([style*="background"])) {
|
||||||
|
background: var(--color-primary, #6366f1);
|
||||||
|
}
|
||||||
|
:deep(.fc-daygrid-day-frame) { min-height: 5rem; }
|
||||||
|
:deep(.fc-scrollgrid) { border-color: var(--color-border, #2a2b30); }
|
||||||
|
:deep(.fc-scrollgrid td),
|
||||||
|
:deep(.fc-scrollgrid th) { border-color: var(--color-border, #2a2b30); }
|
||||||
|
:deep(.fc-daygrid-day) { cursor: pointer; }
|
||||||
|
:deep(.fc-daygrid-day:hover) { background: rgba(255,255,255,0.03); }
|
||||||
|
</style>
|
||||||
@@ -7,13 +7,16 @@ from datetime import date, datetime
|
|||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
from fabledassistant.services.caldav import (
|
from fabledassistant.services.caldav import (
|
||||||
create_event,
|
|
||||||
delete_event,
|
|
||||||
is_caldav_configured,
|
is_caldav_configured,
|
||||||
list_calendars,
|
list_calendars,
|
||||||
list_events,
|
)
|
||||||
search_events,
|
from fabledassistant.services.events import (
|
||||||
update_event,
|
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.config import Config
|
||||||
from fabledassistant.services.notes import create_note, delete_note, get_note_by_title, list_notes, update_note
|
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",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -609,6 +608,10 @@ _CALDAV_TOOLS = [
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional event location",
|
"description": "Optional event location",
|
||||||
},
|
},
|
||||||
|
"color": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional hex color for the event (e.g. '#6366f1')",
|
||||||
|
},
|
||||||
"all_day": {
|
"all_day": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Set to true for all-day events like birthdays, holidays, deadlines (default false)",
|
"description": "Set to true for all-day events like birthdays, holidays, deadlines (default false)",
|
||||||
@@ -626,14 +629,14 @@ _CALDAV_TOOLS = [
|
|||||||
"items": {"type": "string"},
|
"items": {"type": "string"},
|
||||||
"description": "Optional list of attendee email addresses",
|
"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": {
|
"calendar_name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional calendar name to create the event in. Falls back to default calendar.",
|
"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"],
|
"required": ["title", "start"],
|
||||||
},
|
},
|
||||||
@@ -701,6 +704,10 @@ _CALDAV_TOOLS = [
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "New end datetime in ISO 8601 format",
|
"description": "New end datetime in ISO 8601 format",
|
||||||
},
|
},
|
||||||
|
"all_day": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether the event is all-day",
|
||||||
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "New event description",
|
"description": "New event description",
|
||||||
@@ -709,13 +716,13 @@ _CALDAV_TOOLS = [
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "New event location",
|
"description": "New event location",
|
||||||
},
|
},
|
||||||
"timezone": {
|
"color": {
|
||||||
"type": "string",
|
"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",
|
"type": "string",
|
||||||
"description": "Optional calendar name to search in",
|
"description": "New iCalendar RRULE",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": ["query"],
|
"required": ["query"],
|
||||||
@@ -734,15 +741,15 @@ _CALDAV_TOOLS = [
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Search term to find the event to delete (matches against title)",
|
"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"],
|
"required": ["query"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# CalDAV tools — only included when user has CalDAV configured
|
||||||
|
_CALDAV_TOOLS = [
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -1223,17 +1230,45 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
elif tool_name == "create_event":
|
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,
|
user_id=user_id,
|
||||||
title=arguments.get("title", "Untitled Event"),
|
title=arguments.get("title", "Untitled Event"),
|
||||||
start=arguments["start"],
|
start_dt=start_dt,
|
||||||
end=arguments.get("end"),
|
end_dt=end_dt,
|
||||||
duration=arguments.get("duration"),
|
all_day=all_day,
|
||||||
description=arguments.get("description"),
|
description=arguments.get("description") or "",
|
||||||
location=arguments.get("location"),
|
location=arguments.get("location") or "",
|
||||||
all_day=arguments.get("all_day", False),
|
color=arguments.get("color") or "",
|
||||||
recurrence=arguments.get("recurrence"),
|
recurrence=arguments.get("recurrence"),
|
||||||
timezone=arguments.get("timezone"),
|
project_id=project_id,
|
||||||
|
duration=arguments.get("duration"),
|
||||||
reminder_minutes=arguments.get("reminder_minutes"),
|
reminder_minutes=arguments.get("reminder_minutes"),
|
||||||
attendees=arguments.get("attendees"),
|
attendees=arguments.get("attendees"),
|
||||||
calendar_name=arguments.get("calendar_name"),
|
calendar_name=arguments.get("calendar_name"),
|
||||||
@@ -1241,26 +1276,31 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"type": "event",
|
"type": "event",
|
||||||
"data": result,
|
"data": event.to_dict(),
|
||||||
}
|
}
|
||||||
|
|
||||||
elif tool_name == "list_events":
|
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,
|
user_id=user_id,
|
||||||
date_from=arguments["date_from"],
|
date_from=date_from,
|
||||||
date_to=arguments["date_to"],
|
date_to=date_to,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"type": "events",
|
"type": "events",
|
||||||
"data": {
|
"data": {
|
||||||
"count": len(events),
|
"count": len(events),
|
||||||
"events": events,
|
"events": [e.to_dict() for e in events],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
elif tool_name == "search_events":
|
elif tool_name == "search_events":
|
||||||
events = await search_events(
|
events = await events_search_events(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
query=arguments.get("query", ""),
|
query=arguments.get("query", ""),
|
||||||
)
|
)
|
||||||
@@ -1270,38 +1310,53 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
"data": {
|
"data": {
|
||||||
"query": arguments.get("query", ""),
|
"query": arguments.get("query", ""),
|
||||||
"count": len(events),
|
"count": len(events),
|
||||||
"events": events,
|
"events": [e.to_dict() for e in events],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
elif tool_name == "update_event":
|
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,
|
user_id=user_id,
|
||||||
query=arguments["query"],
|
event_id=event_to_update.id,
|
||||||
title=arguments.get("title"),
|
**fields,
|
||||||
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"),
|
|
||||||
)
|
)
|
||||||
|
if updated is None:
|
||||||
|
return {"success": False, "error": "Event not found or update failed."}
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"type": "event_updated",
|
"type": "event_updated",
|
||||||
"data": result,
|
"data": updated.to_dict(),
|
||||||
}
|
}
|
||||||
|
|
||||||
elif tool_name == "delete_event":
|
elif tool_name == "delete_event":
|
||||||
result = await delete_event(
|
query = arguments.get("query", "")
|
||||||
user_id=user_id,
|
matches = await find_events_by_query(user_id=user_id, query=query)
|
||||||
query=arguments["query"],
|
if not matches:
|
||||||
calendar_name=arguments.get("calendar_name"),
|
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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"type": "event_deleted",
|
"type": "event_deleted",
|
||||||
"data": result,
|
"data": {"id": event_to_delete.id, "title": event_to_delete.title},
|
||||||
}
|
}
|
||||||
|
|
||||||
elif tool_name == "list_calendars":
|
elif tool_name == "list_calendars":
|
||||||
|
|||||||
Reference in New Issue
Block a user