From 1a8fee789ba08900d85fa202c4f2b60dfedcbc4c Mon Sep 17 00:00:00 2001 From: Akash Tom Date: Mon, 9 Mar 2026 17:22:59 +0530 Subject: [PATCH 01/44] feat: init calendar --- frappe-ui | 2 +- frontend/components.d.ts | 1 + .../src/components/Calendar/AppSidebar.vue | 66 +++++++++++++++++++ .../src/components/Icons/CalendarLogo.vue | 11 ++++ frontend/src/pages/calendar/CalendarView.vue | 46 +++++++++++++ frontend/src/router.ts | 6 ++ mail/api/calendar.py | 22 +++++++ 7 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/Calendar/AppSidebar.vue create mode 100644 frontend/src/components/Icons/CalendarLogo.vue create mode 100644 frontend/src/pages/calendar/CalendarView.vue create mode 100644 mail/api/calendar.py diff --git a/frappe-ui b/frappe-ui index 29c3c3a80..01bcaacc7 160000 --- a/frappe-ui +++ b/frappe-ui @@ -1 +1 @@ -Subproject commit 29c3c3a808bdf583408ba13e2484206f8a21e0c0 +Subproject commit 01bcaacc7b98e54aa843969f195373315d51d643 diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 460b4f652..2abac875a 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -31,6 +31,7 @@ declare module 'vue' { AttachmentCapsule: typeof import('./src/components/AttachmentCapsule.vue')['default'] AttachmentViewer: typeof import('./src/components/AttachmentViewer.vue')['default'] AudioIcon: typeof import('./src/components/Icons/AudioIcon.vue')['default'] + CalendarLogo: typeof import('./src/components/Icons/CalendarLogo.vue')['default'] ChangePasswordModal: typeof import('./src/components/Modals/ChangePasswordModal.vue')['default'] ComposeMailEditor: typeof import('./src/components/ComposeMailEditor.vue')['default'] ComposeMailToolbar: typeof import('./src/components/ComposeMailToolbar.vue')['default'] diff --git a/frontend/src/components/Calendar/AppSidebar.vue b/frontend/src/components/Calendar/AppSidebar.vue new file mode 100644 index 000000000..dbd926969 --- /dev/null +++ b/frontend/src/components/Calendar/AppSidebar.vue @@ -0,0 +1,66 @@ + + + diff --git a/frontend/src/components/Icons/CalendarLogo.vue b/frontend/src/components/Icons/CalendarLogo.vue new file mode 100644 index 000000000..1c22405c8 --- /dev/null +++ b/frontend/src/components/Icons/CalendarLogo.vue @@ -0,0 +1,11 @@ + diff --git a/frontend/src/pages/calendar/CalendarView.vue b/frontend/src/pages/calendar/CalendarView.vue new file mode 100644 index 000000000..0a3234b4b --- /dev/null +++ b/frontend/src/pages/calendar/CalendarView.vue @@ -0,0 +1,46 @@ + + + diff --git a/frontend/src/router.ts b/frontend/src/router.ts index a288cbc37..23ffea318 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -158,6 +158,12 @@ const routes = [ props: true, meta: { isDashboard: true }, }, + { + path: '/calendar', + name: 'Calendar', + component: () => import('@/pages/calendar/CalendarView.vue'), + meta: { noLayout: true }, + }, ] const router = createRouter({ history: createWebHistory('/mail'), routes }) diff --git a/mail/api/calendar.py b/mail/api/calendar.py new file mode 100644 index 000000000..32a39bca1 --- /dev/null +++ b/mail/api/calendar.py @@ -0,0 +1,22 @@ +import frappe + +from mail.client.doctype.calendar.calendar import fetch_calendars +from mail.client.doctype.calendar_event.calendar_event import fetch_calendar_events + + +@frappe.whitelist() +def get_calendars() -> list[dict[str, str]]: + """Returns a list of the current user's calendars.""" + + calendars = fetch_calendars(frappe.session.user) + + return [{key: cal[key] for key in ["name", "_name"]} for cal in calendars] + + +@frappe.whitelist() +def get_calendar_events(from_date: str, to_date: str) -> list[dict]: + """Fetches calendar events between from_date and to_date for the current user.""" + + events = fetch_calendar_events(frappe.session.user, {"after": from_date, "before": to_date}) + + return events From 7c9e1b1b2a4b9d7c7e22c4dfc37b8c28bd7eebf4 Mon Sep 17 00:00:00 2001 From: Akash Tom Date: Tue, 10 Mar 2026 14:56:15 +0530 Subject: [PATCH 02/44] feat: show events --- frappe-ui | 2 +- frontend/components.d.ts | 1 + .../AppSidebar.vue => CalendarSidebar.vue} | 0 frontend/src/pages/calendar/CalendarView.vue | 30 +++++++++++++++---- mail/api/calendar.py | 2 +- 5 files changed, 27 insertions(+), 8 deletions(-) rename frontend/src/components/{Calendar/AppSidebar.vue => CalendarSidebar.vue} (100%) diff --git a/frappe-ui b/frappe-ui index 01bcaacc7..4362c6535 160000 --- a/frappe-ui +++ b/frappe-ui @@ -1 +1 @@ -Subproject commit 01bcaacc7b98e54aa843969f195373315d51d643 +Subproject commit 4362c65353fea95052e36cb917b8156c423d4bfc diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 2abac875a..e3b9f87e9 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -32,6 +32,7 @@ declare module 'vue' { AttachmentViewer: typeof import('./src/components/AttachmentViewer.vue')['default'] AudioIcon: typeof import('./src/components/Icons/AudioIcon.vue')['default'] CalendarLogo: typeof import('./src/components/Icons/CalendarLogo.vue')['default'] + CalendarSidebar: typeof import('./src/components/CalendarSidebar.vue')['default'] ChangePasswordModal: typeof import('./src/components/Modals/ChangePasswordModal.vue')['default'] ComposeMailEditor: typeof import('./src/components/ComposeMailEditor.vue')['default'] ComposeMailToolbar: typeof import('./src/components/ComposeMailToolbar.vue')['default'] diff --git a/frontend/src/components/Calendar/AppSidebar.vue b/frontend/src/components/CalendarSidebar.vue similarity index 100% rename from frontend/src/components/Calendar/AppSidebar.vue rename to frontend/src/components/CalendarSidebar.vue diff --git a/frontend/src/pages/calendar/CalendarView.vue b/frontend/src/pages/calendar/CalendarView.vue index 0a3234b4b..d2e38d29f 100644 --- a/frontend/src/pages/calendar/CalendarView.vue +++ b/frontend/src/pages/calendar/CalendarView.vue @@ -1,9 +1,11 @@ @@ -32,10 +49,11 @@ const events = createResource({ From bd5afe8f20f0c9a360850b82e58f7c87ac644998 Mon Sep 17 00:00:00 2001 From: Akash Tom Date: Thu, 12 Mar 2026 18:26:45 +0530 Subject: [PATCH 05/44] feat: show calendars --- frontend/src/components/CalendarSidebar.vue | 47 ++++++++++++------- .../Modals/AddCalendarEventModal.vue | 1 + frontend/src/pages/calendar/CalendarView.vue | 39 +++++++++++---- 3 files changed, 62 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/CalendarSidebar.vue b/frontend/src/components/CalendarSidebar.vue index dbd926969..4cdbec9f3 100644 --- a/frontend/src/components/CalendarSidebar.vue +++ b/frontend/src/components/CalendarSidebar.vue @@ -1,12 +1,19 @@ diff --git a/frontend/src/components/Modals/AddCalendarEventModal.vue b/frontend/src/components/Modals/AddCalendarEventModal.vue index 2fa112ff0..2e3885cd5 100644 --- a/frontend/src/components/Modals/AddCalendarEventModal.vue +++ b/frontend/src/components/Modals/AddCalendarEventModal.vue @@ -43,6 +43,7 @@ const event = reactive({ ...DEFAULT_EVENT }) watch(show, (val) => { if (!val) return + step.value = 0 Object.assign(event, DEFAULT_EVENT) if (dayjs(selectedEvent.date).format('YYYY-MM-DD') === event.startDate) { event.startTime = dayjs().add(1, 'hour').minute(0).second(0).format('HH:mm') diff --git a/frontend/src/pages/calendar/CalendarView.vue b/frontend/src/pages/calendar/CalendarView.vue index 298134f1e..0a35f7630 100644 --- a/frontend/src/pages/calendar/CalendarView.vue +++ b/frontend/src/pages/calendar/CalendarView.vue @@ -1,5 +1,5 @@ @@ -148,104 +141,103 @@ const PARTICIPANT_COLUMNS = [{ label: __('Email'), key: 'email' }] From 23ed4f67515cdfef290d566a310b284aaae6822f Mon Sep 17 00:00:00 2001 From: Akash Tom Date: Fri, 13 Mar 2026 16:30:45 +0530 Subject: [PATCH 09/44] feat: add repeat settings --- frontend/components.d.ts | 1 + .../Modals/AddCalendarEventModal.vue | 60 ++++++++++-- .../Modals/EventRepeatSettingsModal.vue | 95 +++++++++++++++++++ frontend/src/utils/index.ts | 10 ++ 4 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/Modals/EventRepeatSettingsModal.vue diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 44c486420..5ce6bedd8 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -54,6 +54,7 @@ declare module 'vue' { EditSignatureModal: typeof import('./src/components/Modals/EditSignatureModal.vue')['default'] EmailContent: typeof import('./src/components/EmailContent.vue')['default'] EmojiPicker: typeof import('./src/components/EmojiPicker.vue')['default'] + EventRepeatSettingsModal: typeof import('./src/components/Modals/EventRepeatSettingsModal.vue')['default'] ExportSettings: typeof import('./src/components/Settings/ExportSettings.vue')['default'] FrappeLogo: typeof import('./src/components/Icons/FrappeLogo.vue')['default'] HeaderActions: typeof import('./src/components/HeaderActions.vue')['default'] diff --git a/frontend/src/components/Modals/AddCalendarEventModal.vue b/frontend/src/components/Modals/AddCalendarEventModal.vue index 8592bdd3b..3992c94e5 100644 --- a/frontend/src/components/Modals/AddCalendarEventModal.vue +++ b/frontend/src/components/Modals/AddCalendarEventModal.vue @@ -13,7 +13,8 @@ import { createResource, } from 'frappe-ui' -import { raiseToast } from '@/utils' +import { getRepeatFrequencyOptions, raiseToast } from '@/utils' +import EventRepeatSettingsModal from '@/components/Modals/EventRepeatSettingsModal.vue' const show = defineModel() @@ -26,7 +27,8 @@ const dayjs = inject('$dayjs') const DEFAULT_EVENT = { title: '', - isFullDay: true, + isAllDay: true, + repeat: false, startDate: dayjs().format('YYYY-MM-DD'), startTime: '10:00', endDate: dayjs().format('YYYY-MM-DD'), @@ -35,6 +37,7 @@ const DEFAULT_EVENT = { description: '', participants: [] as Array<{ email: string }>, send_scheduling_messages: false, + recurrence_rule: {}, } const event = reactive({ ...DEFAULT_EVENT }) @@ -51,6 +54,36 @@ watch(show, (val) => { } }) +const showRepeatSettings = ref(false) + +watch( + () => showRepeatSettings.value, + (val) => { + if (!val && !event.recurrence_rule?.frequency) event.repeat = false + }, +) + +const repeatMessage = computed(() => { + if (!event.recurrence_rule?.frequency) return '' + const message = __('Every {0} {1}', [ + event.recurrence_rule.interval === 1 ? '' : event.recurrence_rule.interval, + getRepeatFrequencyOptions(event.recurrence_rule.interval) + .find((option) => option.value === event.recurrence_rule.frequency) + ?.label.toLowerCase(), + ]) + + if (event.recurrence_rule?.until) + return __('{0} until {1}', [ + message, + dayjs(event.recurrence_rule.until).format('MMM DD, YYYY'), + ]) + + if (event.recurrence_rule?.count) + return __('{0}, {1} occurrences', [message, event.recurrence_rule.count]) + + return message +}) + const addParticipant = (email: string) => { email = email.trim() if (!email) return @@ -82,7 +115,7 @@ const createEvent = createResource({ makeParams: () => { const start = dayjs(event.startDate + 'T' + event.startTime) let duration: string - if (event.isFullDay) { + if (event.isAllDay) { const startDay = dayjs(event.startDate) const endDay = dayjs(event.endDate) const days = endDay.diff(startDay, 'day') + 1 @@ -108,6 +141,7 @@ const createEvent = createResource({ participants: event.participants, description: event.description, send_scheduling_messages: event.send_scheduling_messages, + recurrence_rule: event.recurrence_rule, } }, onSuccess: () => { @@ -147,7 +181,17 @@ const PARTICIPANT_COLUMNS = [{ label: __('Email'), key: 'email' }] :placeholder="__('Meeting with Team')" autocomplete="off" /> - +
+ + +
+ diff --git a/frontend/src/components/Modals/EventRepeatSettingsModal.vue b/frontend/src/components/Modals/EventRepeatSettingsModal.vue new file mode 100644 index 000000000..99081d74c --- /dev/null +++ b/frontend/src/components/Modals/EventRepeatSettingsModal.vue @@ -0,0 +1,95 @@ + + + diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index e64deea58..3af6851a6 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -319,3 +319,13 @@ export const extractNameFromEmail = (email: string) => .split('@')[0] .replace(/[._-]/g, ' ') .replace(/\b\w/g, (c) => c.toUpperCase()) + +export const getRepeatFrequencyOptions = (interval: number) => [ + { label: interval === 1 ? __('Year') : __('Years'), value: 'yearly' }, + { label: interval === 1 ? __('Month') : __('Months'), value: 'monthly' }, + { label: interval === 1 ? __('Week') : __('Weeks'), value: 'weekly' }, + { label: interval === 1 ? __('Day') : __('Days'), value: 'daily' }, + { label: interval === 1 ? __('Hour') : __('Hours'), value: 'hourly' }, + { label: interval === 1 ? __('Minute') : __('Minutes'), value: 'minutely' }, + { label: interval === 1 ? __('Second') : __('Seconds'), value: 'secondly' }, +] From 5069e86a784b1e357ad4e21fecf3b07b72829894 Mon Sep 17 00:00:00 2001 From: Akash Tom Date: Mon, 16 Mar 2026 15:17:54 +0530 Subject: [PATCH 10/44] feat: add view event --- ...ntModal.vue => EditCalendarEventModal.vue} | 95 +++++++++++-------- frontend/src/pages/calendar/CalendarView.vue | 23 +++-- 2 files changed, 72 insertions(+), 46 deletions(-) rename frontend/src/components/Modals/{AddCalendarEventModal.vue => EditCalendarEventModal.vue} (74%) diff --git a/frontend/src/components/Modals/AddCalendarEventModal.vue b/frontend/src/components/Modals/EditCalendarEventModal.vue similarity index 74% rename from frontend/src/components/Modals/AddCalendarEventModal.vue rename to frontend/src/components/Modals/EditCalendarEventModal.vue index 3992c94e5..d22ad5cb5 100644 --- a/frontend/src/components/Modals/AddCalendarEventModal.vue +++ b/frontend/src/components/Modals/EditCalendarEventModal.vue @@ -25,33 +25,56 @@ const emit = defineEmits(['reload-events']) const user = inject('$user') const dayjs = inject('$dayjs') -const DEFAULT_EVENT = { - title: '', - isAllDay: true, - repeat: false, - startDate: dayjs().format('YYYY-MM-DD'), - startTime: '10:00', - endDate: dayjs().format('YYYY-MM-DD'), - endTime: '10:30', - location: '', - description: '', - participants: [] as Array<{ email: string }>, - send_scheduling_messages: false, - recurrence_rule: {}, +const getDefaultEvent = () => { + const startTime = dayjs(selectedEvent.date).isToday() + ? dayjs().add(1, 'hour').minute(0).second(0).format('HH:mm') + : '10:00' + + return { + title: '', + isAllDay: true, + repeat: false, + startDate: dayjs(selectedEvent.date).format('YYYY-MM-DD'), + startTime, + endDate: dayjs(selectedEvent.date).format('YYYY-MM-DD'), + endTime: dayjs(startTime, 'HH:mm').add(30, 'minute').format('HH:mm'), + location: '', + description: '', + participants: [] as Array<{ email: string }>, + send_scheduling_messages: false, + recurrence_rule: {}, + } } -const event = reactive({ ...DEFAULT_EVENT }) +const getEvent = () => { + const start = dayjs(selectedEvent.calendarEvent?.start) + const duration = dayjs.duration(selectedEvent.calendarEvent?.duration) + const end = start.add(duration) + const isAllDay = + duration.days() > 0 && + duration.hours() === 0 && + duration.minutes() === 0 && + duration.seconds() === 0 -watch(show, (val) => { - if (!val) return - Object.assign(event, DEFAULT_EVENT) - if (dayjs(selectedEvent.date).format('YYYY-MM-DD') === event.startDate) { - event.startTime = dayjs().add(1, 'hour').minute(0).second(0).format('HH:mm') - event.endTime = dayjs(event.startTime, 'HH:mm').add(30, 'minute').format('HH:mm') - } else { - event.startDate = dayjs(selectedEvent.date).format('YYYY-MM-DD') - event.endDate = dayjs(selectedEvent.date).format('YYYY-MM-DD') + return { + title: selectedEvent.calendarEvent.title || '', + isAllDay, + repeat: !!selectedEvent.calendarEvent.recurrence_rule?.frequency, + startDate: start.format('YYYY-MM-DD'), + startTime: start.format('HH:mm'), + endDate: end.format('YYYY-MM-DD'), + endTime: end.format('HH:mm'), + location: selectedEvent.calendarEvent.locations?.[0]?._name || '', + description: selectedEvent.calendarEvent.description || '', + participants: selectedEvent.calendarEvent.participants || [], + recurrence_rule: selectedEvent.calendarEvent.recurrence_rule || {}, } +} + +const event = reactive({}) + +watch(show, (val) => { + if (val) Object.assign(event, selectedEvent?.calendarEvent ? getEvent() : getDefaultEvent()) }) const showRepeatSettings = ref(false) @@ -116,19 +139,14 @@ const createEvent = createResource({ const start = dayjs(event.startDate + 'T' + event.startTime) let duration: string if (event.isAllDay) { - const startDay = dayjs(event.startDate) - const endDay = dayjs(event.endDate) - const days = endDay.diff(startDay, 'day') + 1 - duration = 'P' + days + 'D' + const days = dayjs(event.endDate).diff(dayjs(event.startDate), 'day') + 1 + duration = dayjs.duration({ days }).toISOString() } else { - duration = 'PT' const end = dayjs(event.endDate + 'T' + event.endTime) - const totalMinutes = end.diff(start, 'minute') - const hours = Math.floor(totalMinutes / 60) - const minutes = totalMinutes % 60 - if (hours > 0) duration += hours + 'H' - if (minutes > 0) duration += minutes + 'M' - if (hours === 0 && minutes === 0) duration += '0M' + const diff = dayjs.duration(end.diff(start)) + const hours = Math.floor(diff.asHours()) + const minutes = diff.minutes() + duration = dayjs.duration({ hours, minutes }).toISOString() } return { @@ -137,7 +155,7 @@ const createEvent = createResource({ title: event.title, start: start.format('YYYY-MM-DD[T]HH:mm:ss'), duration, - locations: [{ _name: event.location }], + locations: [{ name: event.location }], participants: event.participants, description: event.description, send_scheduling_messages: event.send_scheduling_messages, @@ -163,7 +181,7 @@ const mailContacts = createResource({ const debouncedSearch = useDebounceFn((text: string) => text && mailContacts.reload(text), 300) const dialogOptions = computed(() => ({ - title: __('Add Event'), + title: selectedEvent?.calendarEvent ? __('Edit Event') : __('Add Event'), size: '2xl', actions: [{ label: __('Save'), variant: 'solid', onClick: () => createEvent.submit() }], })) @@ -240,12 +258,12 @@ const PARTICIPANT_COLUMNS = [{ label: __('Email'), key: 'email' }]
-

{{ __('Enter Participants') }}

+

{{ __('Participants') }}

@@ -278,6 +296,7 @@ const PARTICIPANT_COLUMNS = [{ label: __('Email'), key: 'email' }] { +const handleOpenEvent = (e) => { Object.assign(event, e) - showAddEvent.value = true + showEditEvent.value = true } + +watch( + () => showEditEvent.value, + (val) => { + if (!val) Object.keys(event).forEach((key) => delete event[key]) + }, +) diff --git a/frontend/src/components/Modals/EventRepeatSettingsModal.vue b/frontend/src/components/Modals/EventRepeatSettingsModal.vue index 99081d74c..32a4e4132 100644 --- a/frontend/src/components/Modals/EventRepeatSettingsModal.vue +++ b/frontend/src/components/Modals/EventRepeatSettingsModal.vue @@ -2,6 +2,7 @@