Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Schedule page stores #729

Merged
merged 6 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontend/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -333,7 +333,7 @@ export type Fetch = (url: string) => UseFetchReturn<any> & PromiseLike<UseFetchR
export type InviteListResponse = UseFetchReturn<Invite[]|Exception>;
export type Refresh = () => Promise<void>;
export type RemoteEventListResponse = UseFetchReturn<RemoteEvent[]>;
export type ScheduleResponse = UseFetchReturn<Schedule>;
export type ScheduleResponse = UseFetchReturn<Schedule|Exception>;
export type ScheduleListResponse = UseFetchReturn<Schedule[]>;
export type SignatureResponse = UseFetchReturn<Signature>;
export type SlotResponse = UseFetchReturn<Slot|Exception>;
Expand Down
60 changes: 57 additions & 3 deletions frontend/src/stores/calendar-store.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
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);

// Data
const calendars = ref<Calendar[]>([]);
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<RemoteEvent[]>([]);
// List of month batches already called.
const remoteMonthsRetrieved = ref<string[]>([]);

const connectGoogleCalendar = async (call: Fetch, email: string) => {
const urlFriendlyEmail = encodeURIComponent(email);
const googleUrl = await call(`google/auth?email=${urlFriendlyEmail}`).get();
Expand Down Expand Up @@ -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');
devmount marked this conversation as resolved.
Show resolved Hide resolved

// 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)) {
devmount marked this conversation as resolved.
Show resolved Hide resolved
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
*/
Expand All @@ -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();
};
Expand All @@ -68,12 +120,14 @@ export const useCalendarStore = defineStore('calendars', () => {
calendars,
unconnectedCalendars,
connectedCalendars,
remoteEvents,
fetch,
$reset,
connectGoogleCalendar,
connectCalendar,
disconnectCalendar,
syncCalendars,
calendarById,
getRemoteEvents,
};
});
31 changes: 21 additions & 10 deletions frontend/src/stores/schedule-store.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -19,11 +19,12 @@ export const useScheduleStore = defineStore('schedules', () => {

// Data
const schedules = ref<Schedule[]>([]);
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
*/
Expand All @@ -43,18 +44,18 @@ export const useScheduleStore = defineStore('schedules', () => {
};

/**
* Restore default state, empty and unload calendars
* Restore default state, empty and unload schedules
devmount marked this conversation as resolved.
Show resolved Hide resolved
*/
const $reset = () => {
schedules.value = [];
isLoaded.value = false;
};

const handleErrorResponse = (responseData) => {
const handleErrorResponse = (responseData: Ref<Exception>) => {
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) {
Expand Down Expand Up @@ -108,7 +109,7 @@ export const useScheduleStore = defineStore('schedules', () => {
if (error.value) {
return {
error: true,
message: handleErrorResponse(data),
message: handleErrorResponse(data as Ref<Exception>),
} as Error;
}

Expand All @@ -129,7 +130,7 @@ export const useScheduleStore = defineStore('schedules', () => {
if (error.value) {
return {
error: true,
message: handleErrorResponse(data),
message: handleErrorResponse(data as Ref<Exception>),
} as Error;
}

Expand Down Expand Up @@ -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,
};
});
67 changes: 15 additions & 52 deletions frontend/src/views/ScheduleView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,19 @@ 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';
import { dayjsKey, callKey, refreshKey } from '@/keys';
import {
RemoteEvent,
RemoteEventListResponse,
Schedule,
ScheduleListResponse,
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';
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';
Expand All @@ -37,11 +29,13 @@ 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 { connectedCalendars, remoteEvents } = storeToRefs(calendarStore);
const { data: userActivityData } = storeToRefs(userActivityStore);

// current selected date, if not in route: defaults to now
Expand All @@ -54,35 +48,8 @@ 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<RemoteEvent[]>([]);
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 schedules = ref<Schedule[]>([]);
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([]);
Expand All @@ -94,21 +61,18 @@ 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);

activeDate.value = start.add(end.diff(start, 'minutes') / 2, 'minutes');

// 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);
}
};

Expand All @@ -117,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
devmount marked this conversation as resolved.
Show resolved Hide resolved
schedules.value = [{
active: false,
name: '',
Expand Down Expand Up @@ -145,11 +110,9 @@ onMounted(async () => {
return;
}
await refresh();
await getFirstSchedule();
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 = () => {
Expand Down Expand Up @@ -204,7 +167,7 @@ const dismiss = () => {
:calendars="connectedCalendars"
:schedule="firstSchedule"
:active-date="activeDate"
@created="getFirstSchedule"
@created="scheduleStore.fetch(call, true)"
devmount marked this conversation as resolved.
Show resolved Hide resolved
@updated="schedulePreview"
/>
</div>
Expand All @@ -213,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"
/>
Expand Down