diff --git a/backend/src/appointment/database/schemas.py b/backend/src/appointment/database/schemas.py index 23b25e5ce..d7cec473e 100644 --- a/backend/src/appointment/database/schemas.py +++ b/backend/src/appointment/database/schemas.py @@ -108,6 +108,12 @@ class Config: from_attributes = True +class AppointmentWithCalendarOut(Appointment): + """For /me/appointments""" + calendar_title: str + calendar_color: str + + class AppointmentOut(AppointmentBase): id: int | None = None owner_name: str | None = None diff --git a/backend/src/appointment/routes/api.py b/backend/src/appointment/routes/api.py index 17c9b007a..d69a5dd15 100644 --- a/backend/src/appointment/routes/api.py +++ b/backend/src/appointment/routes/api.py @@ -63,12 +63,17 @@ def read_my_calendars( return [schemas.CalendarOut(id=c.id, title=c.title, color=c.color, connected=c.connected) for c in calendars] -@router.get("/me/appointments", response_model=list[schemas.Appointment]) +@router.get("/me/appointments", response_model=list[schemas.AppointmentWithCalendarOut]) def read_my_appointments(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """get all appointments of authenticated subscriber""" if not subscriber: raise HTTPException(status_code=401, detail="No valid authentication credentials provided") appointments = repo.get_appointments_by_subscriber(db, subscriber_id=subscriber.id) + # Mix in calendar title and color. + # Note because we `__dict__` any relationship values won't be carried over, so don't forget to manually add those! + appointments = map( + lambda x: schemas.AppointmentWithCalendarOut(**x.__dict__, slots=x.slots, calendar_title=x.calendar.title, + calendar_color=x.calendar.color), appointments) return appointments diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 26d680097..0fad1a3b8 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -10,10 +10,7 @@
- +
@@ -32,23 +29,23 @@ diff --git a/frontend/src/components/SettingsAccount.vue b/frontend/src/components/SettingsAccount.vue index 4f309c51b..0d59918c4 100644 --- a/frontend/src/components/SettingsAccount.vue +++ b/frontend/src/components/SettingsAccount.vue @@ -155,16 +155,19 @@ import TextButton from '@/elements/TextButton.vue'; // icons import { IconExternalLink } from '@tabler/icons-vue'; +// stores +import { useExternalConnectionsStore } from '@/stores/external-connections-store'; + // component constants const { t } = useI18n({ useScope: 'global' }); const call = inject('call'); -const refresh = inject('refresh'); const router = useRouter(); const user = useUserStore(); +const externalConnectionsStore = useExternalConnectionsStore(); -const externalConnections = ref({}); -const hasZoomAccountConnected = computed(() => (externalConnections.value?.zoom?.length ?? []) > 0); -const zoomAccountName = computed(() => (externalConnections.value?.zoom[0].name ?? null)); +// Currently we only support one zoom account being connected at once. +const hasZoomAccountConnected = computed(() => (externalConnectionsStore.zoom.length) > 0); +const zoomAccountName = computed(() => (externalConnectionsStore.zoom[0]?.name ?? null)); const activeUsername = ref(user.data.username); const activeDisplayName = ref(user.data.name); @@ -195,15 +198,9 @@ const getSignedUserUrl = async () => { signedUserUrl.value = data.value.url; }; -const getExternalConnections = async () => { - const { data } = await call('account/external-connections').get().json(); - externalConnections.value = data.value; -}; - const refreshData = async () => Promise.all([ getSignedUserUrl(), - getExternalConnections(), - refresh(), + externalConnectionsStore.fetch(call), ]); // save user data @@ -251,6 +248,7 @@ const connectZoom = async () => { }; const disconnectZoom = async () => { await call('zoom/disconnect').post(); + await useExternalConnectionsStore().reset(); await refreshData(); }; diff --git a/frontend/src/components/SettingsCalendar.vue b/frontend/src/components/SettingsCalendar.vue index f2041e561..39d524f5f 100644 --- a/frontend/src/components/SettingsCalendar.vue +++ b/frontend/src/components/SettingsCalendar.vue @@ -8,7 +8,7 @@ import { calendarManagementType } from '@/definitions'; import { IconArrowRight } from '@tabler/icons-vue'; -import { ref, reactive, inject, onMounted, computed } from 'vue'; +import { + ref, reactive, inject, onMounted, computed, +} from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute, useRouter } from 'vue-router'; import AlertBox from '@/elements/AlertBox'; @@ -208,12 +210,14 @@ import GoogleSignInBtn from '@/assets/img/google/1x/btn_google_signin_light_norm import GoogleSignInBtn2x from '@/assets/img/google/2x/btn_google_signin_light_normal_web@2x.png'; import PrimaryButton from '@/elements/PrimaryButton'; import SecondaryButton from '@/elements/SecondaryButton'; -import ConfirmationModal from "@/components/ConfirmationModal.vue"; +import ConfirmationModal from '@/components/ConfirmationModal.vue'; +import { useCalendarStore } from '@/stores/calendar-store'; // component constants const { t } = useI18n({ useScope: 'global' }); const call = inject('call'); const refresh = inject('refresh'); +const calendarStore = useCalendarStore(); const calendarConnectError = ref(''); @@ -223,11 +227,6 @@ const deleteCalendarModalTarget = ref(null); // Temp until we get a store solution rolling const loading = ref(false); -// view properties -defineProps({ - calendars: Array, // list of calendars from db -}); - // handle calendar user input to add or edit calendar connections const inputModes = { hidden: 0, @@ -264,7 +263,9 @@ const closeModals = async () => { }; const refreshData = async () => { - await refresh({ onlyConnectedCalendars: false }); + // Invalidate our calendar store + await calendarStore.reset(); + await refresh(); loading.value = false; }; @@ -392,6 +393,6 @@ onMounted(async () => { await router.replace(route.path); } - await refreshData(); + await refresh(); }); diff --git a/frontend/src/stores/alert-store.js b/frontend/src/stores/alert-store.js index 074c0eac2..9f0d2d153 100644 --- a/frontend/src/stores/alert-store.js +++ b/frontend/src/stores/alert-store.js @@ -13,7 +13,7 @@ const initialSiteNotificationObject = { // eslint-disable-next-line import/prefer-default-export export const useSiteNotificationStore = defineStore('siteNotification', { state: () => ({ - data: initialSiteNotificationObject, + data: structuredClone(initialSiteNotificationObject), }), getters: { isVisible() { @@ -50,7 +50,7 @@ export const useSiteNotificationStore = defineStore('siteNotification', { }); }, reset() { - this.$patch({ data: initialSiteNotificationObject }); + this.$patch({ data: structuredClone(initialSiteNotificationObject) }); }, }, }); diff --git a/frontend/src/stores/appointment-store.js b/frontend/src/stores/appointment-store.js new file mode 100644 index 000000000..e68bed600 --- /dev/null +++ b/frontend/src/stores/appointment-store.js @@ -0,0 +1,66 @@ +import { defineStore } from 'pinia'; +import { appointmentState } from '@/definitions'; +import { useUserStore } from '@/stores/user-store'; +import dj from 'dayjs'; + +const initialData = { + appointments: [], + isInit: false, +}; + +// eslint-disable-next-line import/prefer-default-export +export const useAppointmentStore = defineStore('appointments', { + state: () => ({ + data: structuredClone(initialData), + }), + getters: { + isLoaded() { + return this.data.isInit; + }, + appointments() { + return this.data.appointments; + }, + pendingAppointments() { + return this.data.appointments.filter((a) => a.status === appointmentState.pending); + }, + }, + actions: { + status(appointment) { + // check past events + if (appointment.slots.filter((s) => dj(s.start).isAfter(dj())).length === 0) { + return appointmentState.past; + } + // check booked events + if (appointment.slots.filter((s) => s.attendee_id != null).length > 0) { + return appointmentState.booked; + } + // else event is still wating to be booked + return appointmentState.pending; + }, + reset() { + this.$patch({ data: structuredClone(initialData) }); + }, + async postFetchProcess() { + const userStore = useUserStore(); + + this.data.appointments.forEach((a) => { + a.status = this.status(a); + a.active = a.status !== appointmentState.past; // TODO + // convert start dates from UTC back to users timezone + a.slots.forEach((s) => { + s.start = dj.utc(s.start).tz(userStore.data.timezone ?? dj.tz.guess()); + }); + }); + }, + async fetch(call) { + const { data, error } = await call('me/appointments').get().json(); + if (!error.value) { + if (data.value === null || typeof data.value === 'undefined') return; + this.data.appointments = data.value; + this.data.isInit = true; + } + // After we fetch the data, apply some processing + await this.postFetchProcess(); + }, + }, +}); diff --git a/frontend/src/stores/calendar-store.js b/frontend/src/stores/calendar-store.js new file mode 100644 index 000000000..589b8d4ce --- /dev/null +++ b/frontend/src/stores/calendar-store.js @@ -0,0 +1,44 @@ +import { defineStore } from 'pinia'; + +const initialData = { + calendars: [], + isInit: false, +}; + +// eslint-disable-next-line import/prefer-default-export +export const useCalendarStore = defineStore('calendars', { + state: () => ({ + data: structuredClone(initialData), + }), + getters: { + isLoaded() { + return this.data.isInit; + }, + unconnectedCalendars() { + return this.data.calendars.filter((cal) => !cal.connected); + }, + connectedCalendars() { + return this.data.calendars.filter((cal) => cal.connected); + }, + allCalendars() { + return this.data.calendars; + }, + }, + actions: { + reset() { + this.$patch({ data: structuredClone(initialData) }); + }, + async fetch(call) { + if (this.isLoaded) { + return; + } + + const { data, error } = await call('me/calendars?only_connected=false').get().json(); + if (!error.value) { + if (data.value === null || typeof data.value === 'undefined') return; + this.data.calendars = data.value; + this.data.isInit = true; + } + }, + }, +}); diff --git a/frontend/src/stores/external-connections-store.js b/frontend/src/stores/external-connections-store.js new file mode 100644 index 000000000..c3748fa12 --- /dev/null +++ b/frontend/src/stores/external-connections-store.js @@ -0,0 +1,41 @@ +import { defineStore } from 'pinia'; + +const initialData = { + zoom: [], + fxa: [], + isInit: false, +}; + +// eslint-disable-next-line import/prefer-default-export +export const useExternalConnectionsStore = defineStore('externalConnections', { + state: () => ({ + data: structuredClone(initialData), + }), + getters: { + isLoaded() { + return this.data.isInit; + }, + connections() { + return this.data; + }, + fxa() { + return this.data.fxa ?? []; + }, + zoom() { + return this.data.zoom ?? []; + }, + }, + actions: { + reset() { + this.$patch({ data: structuredClone(initialData) }); + }, + async fetch(call) { + if (this.isLoaded) { + return; + } + + const { data } = await call('account/external-connections').get().json(); + this.$patch({ data: { ...data.value, isInit: true } }); + }, + }, +}); diff --git a/frontend/src/stores/user-store.js b/frontend/src/stores/user-store.js index 542428436..74f63aa62 100644 --- a/frontend/src/stores/user-store.js +++ b/frontend/src/stores/user-store.js @@ -20,7 +20,7 @@ export const useUserStore = defineStore('user', { return this.data.accessToken !== null; }, reset() { - this.$patch({ data: initialUserObject }); + this.$patch({ data: structuredClone(initialUserObject) }); }, async profile(fetch) { const { error, data } = await fetch('me').get().json(); diff --git a/frontend/src/views/AppointmentsView.vue b/frontend/src/views/AppointmentsView.vue index 3f0b84878..2c2824cf0 100644 --- a/frontend/src/views/AppointmentsView.vue +++ b/frontend/src/views/AppointmentsView.vue @@ -6,7 +6,7 @@ @@ -202,7 +202,7 @@ { // handle filtered appointments list const filteredAppointments = computed(() => { - let list = props.appointments ? [...props.appointments] : []; + let list = appointmentStore.appointments ? [...appointmentStore.appointments] : []; // by search input if (search.value !== '') { list = list.filter((a) => a.title.toLowerCase().includes(search.value.toLowerCase())); diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue index 7891d1850..a0a2162df 100644 --- a/frontend/src/views/CalendarView.vue +++ b/frontend/src/views/CalendarView.vue @@ -1,6 +1,6 @@ diff --git a/frontend/src/views/ProfileView.vue b/frontend/src/views/ProfileView.vue index 0439fde6d..9b8c976e9 100644 --- a/frontend/src/views/ProfileView.vue +++ b/frontend/src/views/ProfileView.vue @@ -16,12 +16,12 @@
-
{{ calendars.length }}
+
{{ calendarStore.connectedCalendars.length }}/∞
{{ t('heading.calendarsConnected') }}
-
{{ pendingAppointments.length }}
+
{{ appointmentStore.pendingAppointments.length }}
{{ t('heading.pendingAppointments') }}
@@ -31,10 +31,10 @@ diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue index ae601c256..026515134 100644 --- a/frontend/src/views/ScheduleView.vue +++ b/frontend/src/views/ScheduleView.vue @@ -38,7 +38,7 @@
@@ -67,7 +67,7 @@ v-show="tabActive === calendarViews.day" class="w-full md:w-4/5" :selected="activeDate" - :appointments="pendingAppointments" + :appointments="appointmentStore.pendingAppointments" :events="calendarEvents" popup-position="top" /> @@ -75,7 +75,7 @@