From 84f0eeef375af7b2c2bbbc7b0cde107180a5937a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Wed, 30 Oct 2024 15:51:53 +0100 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9E=95=20Utilize=20existing=20schedule?= =?UTF-8?q?=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/stores/schedule-store.ts | 17 ++++++++++++++--- frontend/src/views/ScheduleView.vue | 13 ++++--------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/frontend/src/stores/schedule-store.ts b/frontend/src/stores/schedule-store.ts index 099e85a1f..296bc7ff2 100644 --- a/frontend/src/stores/schedule-store.ts +++ b/frontend/src/stores/schedule-store.ts @@ -19,11 +19,12 @@ export const useScheduleStore = defineStore('schedules', () => { // Data const schedules = ref([]); + const firstSchedule = computed((): Schedule => schedules.value?.length > 0 ? schedules.value[0] : null); const inactiveSchedules = computed((): Schedule[] => schedules.value.filter((schedule) => !schedule.active)); const activeSchedules = computed((): Schedule[] => schedules.value.filter((schedule) => schedule.active)); /** - * Get all calendars for current user + * Get all schedules for current user * @param call preconfigured API fetch function * @param force Force a fetch even if we already have data */ @@ -43,7 +44,7 @@ export const useScheduleStore = defineStore('schedules', () => { }; /** - * Restore default state, empty and unload calendars + * Restore default state, empty and unload schedules */ const $reset = () => { schedules.value = []; @@ -172,6 +173,16 @@ export const useScheduleStore = defineStore('schedules', () => { }; return { - isLoaded, schedules, inactiveSchedules, activeSchedules, fetch, $reset, createSchedule, updateSchedule, timeToBackendTime, timeToFrontendTime, + isLoaded, + schedules, + firstSchedule, + inactiveSchedules, + activeSchedules, + fetch, + $reset, + createSchedule, + updateSchedule, + timeToBackendTime, + timeToFrontendTime, }; }); diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue index c538c29dd..c997f4fea 100644 --- a/frontend/src/views/ScheduleView.vue +++ b/frontend/src/views/ScheduleView.vue @@ -27,6 +27,7 @@ import NoticeBar from '@/tbpro/elements/NoticeBar.vue'; import PrimaryButton from '@/tbpro/elements/PrimaryButton.vue'; // stores +import { useScheduleStore } from '@/stores/schedule-store'; import { useAppointmentStore } from '@/stores/appointment-store'; import { useCalendarStore } from '@/stores/calendar-store'; import { useUserActivityStore } from '@/stores/user-activity-store'; @@ -37,9 +38,11 @@ const dj = inject(dayjsKey); const call = inject(callKey); const refresh = inject(refreshKey); +const scheduleStore = useScheduleStore(); const appointmentStore = useAppointmentStore(); const calendarStore = useCalendarStore(); const userActivityStore = useUserActivityStore(); +const { schedules, firstSchedule } = storeToRefs(scheduleStore); const { pendingAppointments } = storeToRefs(appointmentStore); const { connectedCalendars } = storeToRefs(calendarStore); const { data: userActivityData } = storeToRefs(userActivityStore); @@ -75,14 +78,7 @@ const getRemoteEvents = async (from: string, to: string) => { }; // user configured schedules from db (only the first for now, later multiple schedules will be available) -const schedules = ref([]); -const firstSchedule = computed(() => (schedules.value?.length > 0 ? schedules.value[0] : null)); const schedulesReady = ref(false); -const getFirstSchedule = async () => { - // trailing slash to prevent fast api redirect which doesn't work great on our container setup - const { data }: ScheduleListResponse = await call('schedule/').get().json(); - schedules.value = data.value; -}; // schedule previews for showing corresponding placeholders in calendar views const schedulesPreviews = ref([]); @@ -145,7 +141,6 @@ onMounted(async () => { return; } await refresh(); - await getFirstSchedule(); schedulesReady.value = true; const eventsFrom = dj(activeDate.value).startOf('month').format('YYYY-MM-DD'); const eventsTo = dj(activeDate.value).endOf('month').format('YYYY-MM-DD'); @@ -204,7 +199,7 @@ const dismiss = () => { :calendars="connectedCalendars" :schedule="firstSchedule" :active-date="activeDate" - @created="getFirstSchedule" + @created="scheduleStore.fetch" @updated="schedulePreview" /> From 794bbcda7f8bf2658b7256ba6209c5550c27dcfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Wed, 30 Oct 2024 16:41:54 +0100 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=94=A8=20Type=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/models.ts | 4 ++-- frontend/src/stores/schedule-store.ts | 14 +++++++------- frontend/src/views/ScheduleView.vue | 9 +++------ 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/frontend/src/models.ts b/frontend/src/models.ts index 84d985dbe..7b709fda4 100644 --- a/frontend/src/models.ts +++ b/frontend/src/models.ts @@ -292,7 +292,7 @@ export type ExceptionDetail = { status?: number; } export type PydanticExceptionDetail = { - ctx: { reason: string }, + ctx: { reason: string, ge?: string }, input: string, loc: string[], msg: string, @@ -333,7 +333,7 @@ export type Fetch = (url: string) => UseFetchReturn & PromiseLike; export type Refresh = () => Promise; export type RemoteEventListResponse = UseFetchReturn; -export type ScheduleResponse = UseFetchReturn; +export type ScheduleResponse = UseFetchReturn; export type ScheduleListResponse = UseFetchReturn; export type SignatureResponse = UseFetchReturn; export type SlotResponse = UseFetchReturn; diff --git a/frontend/src/stores/schedule-store.ts b/frontend/src/stores/schedule-store.ts index 296bc7ff2..b7fe8be99 100644 --- a/frontend/src/stores/schedule-store.ts +++ b/frontend/src/stores/schedule-store.ts @@ -1,10 +1,10 @@ import { i18n } from '@/composables/i18n'; import { defineStore } from 'pinia'; -import { ref, computed, inject } from 'vue'; +import { ref, computed, inject, Ref } from 'vue'; import { useUserStore } from '@/stores/user-store'; import { DateFormatStrings, MetricEvents } from '@/definitions'; import { - Error, Fetch, Schedule, ScheduleListResponse, ScheduleResponse, + Error, Fetch, Schedule, ScheduleListResponse, ScheduleResponse, Exception, ExceptionDetail } from '@/models'; import { dayjsKey } from '@/keys'; import { posthog, usePosthog } from '@/composables/posthog'; @@ -51,11 +51,11 @@ export const useScheduleStore = defineStore('schedules', () => { isLoaded.value = false; }; - const handleErrorResponse = (responseData) => { + const handleErrorResponse = (responseData: Ref) => { const { value } = responseData; - if (value?.detail?.message) { - return value?.detail?.message; + if ((value?.detail as ExceptionDetail)?.message) { + return (value?.detail as ExceptionDetail)?.message; } if (value?.detail instanceof Array) { @@ -109,7 +109,7 @@ export const useScheduleStore = defineStore('schedules', () => { if (error.value) { return { error: true, - message: handleErrorResponse(data), + message: handleErrorResponse(data as Ref), } as Error; } @@ -130,7 +130,7 @@ export const useScheduleStore = defineStore('schedules', () => { if (error.value) { return { error: true, - message: handleErrorResponse(data), + message: handleErrorResponse(data as Ref), } as Error; } diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue index c997f4fea..06b6e494a 100644 --- a/frontend/src/views/ScheduleView.vue +++ b/frontend/src/views/ScheduleView.vue @@ -6,9 +6,7 @@ import { EventLocationType, MeetingLinkProviderType, } from '@/definitions'; -import { - ref, inject, computed, onMounted, -} from 'vue'; +import { ref, inject, onMounted } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute } from 'vue-router'; import { storeToRefs } from 'pinia'; @@ -16,8 +14,6 @@ import { dayjsKey, callKey, refreshKey } from '@/keys'; import { RemoteEvent, RemoteEventListResponse, - Schedule, - ScheduleListResponse, ScheduleAppointment, TimeFormatted, } from '@/models'; @@ -141,6 +137,7 @@ onMounted(async () => { return; } await refresh(); + scheduleStore.fetch(call); schedulesReady.value = true; const eventsFrom = dj(activeDate.value).startOf('month').format('YYYY-MM-DD'); const eventsTo = dj(activeDate.value).endOf('month').format('YYYY-MM-DD'); @@ -199,7 +196,7 @@ const dismiss = () => { :calendars="connectedCalendars" :schedule="firstSchedule" :active-date="activeDate" - @created="scheduleStore.fetch" + @created="scheduleStore.fetch(call, true)" @updated="schedulePreview" /> From 6e7978d8d6602d53a60a24c5753aa9b79dd84d75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Thu, 31 Oct 2024 15:19:47 +0100 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9E=95=20Cache=20remote=20events=20per?= =?UTF-8?q?=20month=20on=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/stores/calendar-store.ts | 60 +++++++++++++++++++++++++-- frontend/src/views/ScheduleView.vue | 47 ++++----------------- 2 files changed, 66 insertions(+), 41 deletions(-) diff --git a/frontend/src/stores/calendar-store.ts b/frontend/src/stores/calendar-store.ts index 146d01c31..bfa539b99 100644 --- a/frontend/src/stores/calendar-store.ts +++ b/frontend/src/stores/calendar-store.ts @@ -1,9 +1,13 @@ -import { Calendar, CalendarListResponse, Fetch } from '@/models'; +import { Calendar, CalendarListResponse, Fetch, RemoteEvent, RemoteEventListResponse } from '@/models'; import { defineStore } from 'pinia'; -import { ref, computed } from 'vue'; +import { ref, computed, inject } from 'vue'; +import { dayjsKey } from '@/keys'; +import { Dayjs } from 'dayjs'; // eslint-disable-next-line import/prefer-default-export export const useCalendarStore = defineStore('calendars', () => { + const dj = inject(dayjsKey); + // State const isLoaded = ref(false); @@ -11,9 +15,13 @@ export const useCalendarStore = defineStore('calendars', () => { const calendars = ref([]); const unconnectedCalendars = computed((): Calendar[] => calendars.value.filter((cal) => !cal.connected)); const connectedCalendars = computed((): Calendar[] => calendars.value.filter((cal) => cal.connected)); - const hasConnectedCalendars = computed(() => connectedCalendars.value.length > 0); + // List of remote events. Retrieved in batches per month. + const remoteEvents = ref([]); + // List of month batches already called. + const remoteMonthsRetrieved = ref([]); + const connectGoogleCalendar = async (call: Fetch, email: string) => { const urlFriendlyEmail = encodeURIComponent(email); const googleUrl = await call(`google/auth?email=${urlFriendlyEmail}`).get(); @@ -44,6 +52,48 @@ export const useCalendarStore = defineStore('calendars', () => { } }; + /** + * Get all cremote events from connected calendars for given time span + * @param call preconfigured API fetch function + * @param activeDate Dayjs object defining the current month + * @param force force a refetch + */ + const getRemoteEvents = async (call: Fetch, activeDate: Dayjs, force = false) => { + // Get month identifier to remember this month's events are already retrieved + const month = activeDate.format('YYYY-MM'); + + // Most calendar impl are non-inclusive of the last day, so just add one day to the end. + const from = activeDate.startOf('month').format('YYYY-MM-DD'); + const to = activeDate.endOf('month').add(1, 'day').format('YYYY-MM-DD'); + + // If retrieval is forced, delete cache and start with zero events again + if (force) { + remoteMonthsRetrieved.value = []; + } + const calendarEvents = force ? [] : [...remoteEvents.value]; + + // Only retrieve remote events if we don't have this month already cached + if (!remoteMonthsRetrieved.value.includes(month)) { + await Promise.all(connectedCalendars.value.map(async (calendar) => { + const { data }: RemoteEventListResponse = await call(`rmt/cal/${calendar.id}/${from}/${to}`).get().json(); + if (Array.isArray(data.value)) { + calendarEvents.push( + ...data.value.map((event) => ({ + ...event, + duration: dj(event.end).diff(dj(event.start), 'minutes'), + })), + ); + } + })); + } + + // Remember month + remoteMonthsRetrieved.value.push(month); + + // Update remote event list + remoteEvents.value = calendarEvents; + }; + /** * Restore default state, empty and unload calendars */ @@ -55,9 +105,11 @@ export const useCalendarStore = defineStore('calendars', () => { const connectCalendar = async (call: Fetch, id: number) => { await call(`cal/${id}/connect`).post(); }; + const disconnectCalendar = async (call: Fetch, id: number) => { await call(`cal/${id}/disconnect`).post(); }; + const syncCalendars = async (call: Fetch) => { await call('rmt/sync').post(); }; @@ -68,6 +120,7 @@ export const useCalendarStore = defineStore('calendars', () => { calendars, unconnectedCalendars, connectedCalendars, + remoteEvents, fetch, $reset, connectGoogleCalendar, @@ -75,5 +128,6 @@ export const useCalendarStore = defineStore('calendars', () => { disconnectCalendar, syncCalendars, calendarById, + getRemoteEvents, }; }); diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue index 06b6e494a..368d8edbc 100644 --- a/frontend/src/views/ScheduleView.vue +++ b/frontend/src/views/ScheduleView.vue @@ -11,12 +11,7 @@ import { useI18n } from 'vue-i18n'; import { useRoute } from 'vue-router'; import { storeToRefs } from 'pinia'; import { dayjsKey, callKey, refreshKey } from '@/keys'; -import { - RemoteEvent, - RemoteEventListResponse, - ScheduleAppointment, - TimeFormatted, -} from '@/models'; +import { ScheduleAppointment, TimeFormatted } from '@/models'; import ScheduleCreation from '@/components/ScheduleCreation.vue'; import CalendarQalendar from '@/components/CalendarQalendar.vue'; import NoticeBar from '@/tbpro/elements/NoticeBar.vue'; @@ -40,7 +35,7 @@ const calendarStore = useCalendarStore(); const userActivityStore = useUserActivityStore(); const { schedules, firstSchedule } = storeToRefs(scheduleStore); const { pendingAppointments } = storeToRefs(appointmentStore); -const { connectedCalendars } = storeToRefs(calendarStore); +const { connectedCalendars, remoteEvents } = storeToRefs(calendarStore); const { data: userActivityData } = storeToRefs(userActivityStore); // current selected date, if not in route: defaults to now @@ -53,26 +48,6 @@ const activeDateRange = ref({ // active menu item for tab navigation of calendar views const tabActive = ref(BookingCalendarView.Month); -// get remote calendar data for current year -const calendarEvents = ref([]); -const getRemoteEvents = async (from: string, to: string) => { - // Most calendar impl are non-inclusive of the last day, so just add one to the day. - const inclusiveTo = dj(to).add(1, 'day').format('YYYY-MM-DD'); - - calendarEvents.value = []; - await Promise.all(connectedCalendars.value.map(async (calendar) => { - const { data }: RemoteEventListResponse = await call(`rmt/cal/${calendar.id}/${from}/${inclusiveTo}`).get().json(); - if (Array.isArray(data.value)) { - calendarEvents.value.push( - ...data.value.map((event) => ({ - ...event, - duration: dj(event.end).diff(dj(event.start), 'minutes'), - })), - ); - } - })); -}; - // user configured schedules from db (only the first for now, later multiple schedules will be available) const schedulesReady = ref(false); @@ -86,7 +61,7 @@ const schedulePreview = (schedule: ScheduleAppointment) => { * Retrieve new events if a user navigates to a different month * @param dateObj */ -const onDateChange = (dateObj: TimeFormatted) => { +const onDateChange = async (dateObj: TimeFormatted) => { const start = dj(dateObj.start); const end = dj(dateObj.end); @@ -94,13 +69,10 @@ const onDateChange = (dateObj: TimeFormatted) => { // remote data is retrieved per month, so a data request happens as soon as the user navigates to a different month if ( - dj(activeDateRange.value.end).format('YYYYMM') !== dj(end).format('YYYYMM') - || dj(activeDateRange.value.start).format('YYYYMM') !== dj(start).format('YYYYMM') + !dj(activeDateRange.value.end).isSame(dj(end), 'month') + || !dj(activeDateRange.value.start).isSame(dj(start), 'month') ) { - getRemoteEvents( - dj(start).format('YYYY-MM-DD'), - dj(end).format('YYYY-MM-DD'), - ); + await calendarStore.getRemoteEvents(call, activeDate.value); } }; @@ -109,6 +81,7 @@ onMounted(async () => { // Don't actually load anything during the FTUE if (route.name === 'setup') { // Setup a fake schedule so the schedule creation bar works correctly... + // TODO: move that to the schedule store as initial/default value schedules.value = [{ active: false, name: '', @@ -139,9 +112,7 @@ onMounted(async () => { await refresh(); scheduleStore.fetch(call); schedulesReady.value = true; - const eventsFrom = dj(activeDate.value).startOf('month').format('YYYY-MM-DD'); - const eventsTo = dj(activeDate.value).endOf('month').format('YYYY-MM-DD'); - await getRemoteEvents(eventsFrom, eventsTo); + await calendarStore.getRemoteEvents(call, activeDate.value); }); const dismiss = () => { @@ -205,7 +176,7 @@ const dismiss = () => { class="w-full md:w-9/12 xl:w-10/12" :selected="activeDate" :appointments="pendingAppointments" - :events="calendarEvents" + :events="remoteEvents" :schedules="schedulesPreviews" @date-change="onDateChange" /> From 4d0eff125ddf9ee02909b4140984f66a08bcf841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Thu, 31 Oct 2024 17:04:06 +0100 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=94=A8=20Active=20date=20improvements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/views/ScheduleView.vue | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue index 368d8edbc..939bcfa8f 100644 --- a/frontend/src/views/ScheduleView.vue +++ b/frontend/src/views/ScheduleView.vue @@ -1,12 +1,11 @@