From 957d19740ed31834e8346d3fa309605a6d657c11 Mon Sep 17 00:00:00 2001 From: Rajiv Sahal Date: Tue, 2 Dec 2025 21:44:30 +0530 Subject: [PATCH] feat: `CalendarView` atom v2 (#24896) * refactor: make individual component for generic and event type specific calendar views * fixup * update examples app * fix: show busy times from calendars in week view * fix: use calendar busy times not working * chore: add changesets * fixup * chore: implement PR feedback * fix: merge conflicts * chore: implement PR feedback * fixup * chore: implement PR feedback --- .changeset/clear-webs-study.md | 5 + .../features/calendar-view/LargeCalendar.tsx | 113 +++++++++--- .../calendar-view/CalendarViewComponent.tsx | 31 ++++ .../EventTypeCalendarViewComponent.tsx | 159 ++++++++++++++++ .../wrappers/CalendarViewPlatformWrapper.tsx | 172 +++--------------- .../atoms/hooks/useCalendarsBusyTimes.ts | 23 +++ .../examples/base/src/pages/calendar-view.tsx | 3 +- 7 files changed, 332 insertions(+), 174 deletions(-) create mode 100644 .changeset/clear-webs-study.md create mode 100644 packages/platform/atoms/calendar-view/CalendarViewComponent.tsx create mode 100644 packages/platform/atoms/calendar-view/EventTypeCalendarViewComponent.tsx diff --git a/.changeset/clear-webs-study.md b/.changeset/clear-webs-study.md new file mode 100644 index 0000000000..c84741939a --- /dev/null +++ b/.changeset/clear-webs-study.md @@ -0,0 +1,5 @@ +--- +"@calcom/atoms": minor +--- + +This PR introduces two variants for the calendar view atom, a generic calendar view and an event type specific view containing bookings data and busy times from connected calendars. diff --git a/packages/features/calendar-view/LargeCalendar.tsx b/packages/features/calendar-view/LargeCalendar.tsx index 538ab9df22..544f38c91c 100644 --- a/packages/features/calendar-view/LargeCalendar.tsx +++ b/packages/features/calendar-view/LargeCalendar.tsx @@ -2,27 +2,28 @@ import { useEffect, useMemo } from "react"; import dayjs from "@calcom/dayjs"; import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider"; -import { useAvailableTimeSlots } from "@calcom/features/bookings/Booker/components/hooks/useAvailableTimeSlots"; import { useBookerTime } from "@calcom/features/bookings/Booker/components/hooks/useBookerTime"; import type { BookerEvent } from "@calcom/features/bookings/types"; import { Calendar } from "@calcom/features/calendars/weeklyview"; +import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/weeklyview/types/state"; import { localStorage } from "@calcom/lib/webstorage"; import type { BookingStatus } from "@calcom/prisma/enums"; import { useBookings } from "../../platform/atoms/hooks/bookings/useBookings"; -import type { useScheduleForEventReturnType } from "../bookings/Booker/utils/event"; +import { useCalendarsBusyTimes } from "../../platform/atoms/hooks/useCalendarsBusyTimes"; +import { useConnectedCalendars } from "../../platform/atoms/hooks/useConnectedCalendars"; import { getQueryParam } from "../bookings/Booker/utils/query-param"; export const LargeCalendar = ({ extraDays, - schedule, + availableTimeslots, isLoading, event, }: { extraDays: number; - schedule?: useScheduleForEventReturnType["data"]; + availableTimeslots?: CalendarAvailableTimeslots | undefined; isLoading: boolean; - event: { + event?: { data?: Pick | null; }; }) => { @@ -34,20 +35,43 @@ export const LargeCalendar = ({ const eventDuration = selectedEventDuration || event?.data?.length || 30; - const availableSlots = useAvailableTimeSlots({ schedule, eventDuration }); + const availableSlots = availableTimeslots !== undefined ? availableTimeslots : undefined; const startDate = selectedDate ? dayjs(selectedDate).toDate() : dayjs().toDate(); const endDate = dayjs(startDate) .add(extraDays - 1, "day") .toDate(); - const { data: upcomingBookings } = useBookings({ + const { data: bookings, isPending: _isFetchingBookings } = useBookings({ take: 150, skip: 0, - status: ["upcoming", "past", "recurring"], eventTypeId: event?.data?.id, afterStart: startDate.toISOString(), beforeEnd: endDate.toISOString(), + status: ["upcoming", "past", "recurring"], + }); + + const { data: connectedCalendars, isPending: isFetchingConnectedCalendars } = useConnectedCalendars({ + enabled: true, + }); + + const calendarsToLoad = connectedCalendars?.connectedCalendars.flatMap((connectedCalendar) => { + return ( + connectedCalendar.calendars + ?.filter((calendar) => calendar.isSelected === true) + .map((cal) => ({ + credentialId: cal.credentialId, + externalId: cal.externalId, + })) ?? [] + ); + }); + + const { data: overlayBusyDates } = useCalendarsBusyTimes({ + loggedInUsersTz: timezone, + dateFrom: startDate.toISOString(), + dateTo: endDate.toISOString(), + calendarsToLoad: calendarsToLoad ?? [], + enabled: Boolean(!isFetchingConnectedCalendars && !!calendarsToLoad?.length), }); // HACK: force rerender when overlay events change @@ -55,23 +79,66 @@ export const LargeCalendar = ({ useEffect(() => {}, [displayOverlay]); - const overlayEventsForDate = useMemo(() => { - if (!upcomingBookings) return []; + const sortedBookingRanges = useMemo( + () => + (bookings ?? []) + .map((booking) => ({ + booking, + startTime: dayjs(booking.start).valueOf(), + endTime: dayjs(booking.end).valueOf(), + })) + .sort((a, b) => a.startTime - b.startTime), + [bookings] + ); - return upcomingBookings?.map((booking) => { - return { - id: booking.id, - title: booking.title ?? `Busy`, - start: new Date(booking.start), - end: new Date(booking.end), - options: { - status: booking.status.toUpperCase() as BookingStatus, - "data-test-id": "troubleshooter-busy-event", - className: "border-[1.5px]", - }, - }; + const filteredBusyDates = useMemo(() => { + const overlay = overlayBusyDates?.data ?? []; + if (!sortedBookingRanges.length) return overlay; + + return overlay.filter((busy) => { + const busyStart = dayjs(busy.start).valueOf(); + const busyEnd = dayjs(busy.end).valueOf(); + + for (const { startTime, endTime } of sortedBookingRanges) { + if (startTime >= busyEnd) break; + if (endTime <= busyStart) continue; + return false; + } + + return true; }); - }, [upcomingBookings]); + }, [overlayBusyDates?.data, sortedBookingRanges]); + + const overlayEventsForDate = useMemo(() => { + const allBookings = bookings ?? []; + // since busy dates comes straight from the calendar, it contains slots have bookings and also slots that are marked as busy by user but are not bookings + // hence we filter overlayBusyDates to exclude anything that overlaps with bookings + const busyEvents = filteredBusyDates.map((busyData, index) => ({ + id: index, + title: `Busy`, + start: new Date(busyData.start), + end: new Date(busyData.end), + options: { + status: "ACCEPTED" as BookingStatus, + "data-test-id": "troubleshooter-busy-event", + className: "border-[1.5px]", + }, + })); + + const bookingEvents = allBookings.map((booking) => ({ + id: booking.id, + title: booking.title ?? `Busy`, + start: new Date(booking.start), + end: new Date(booking.end), + options: { + status: booking.status.toUpperCase() as BookingStatus, + "data-test-id": "troubleshooter-busy-event", + className: "border-[1.5px]", + }, + })); + + return [...bookingEvents, ...busyEvents]; + }, [bookings, filteredBusyDates]); return (
diff --git a/packages/platform/atoms/calendar-view/CalendarViewComponent.tsx b/packages/platform/atoms/calendar-view/CalendarViewComponent.tsx new file mode 100644 index 0000000000..5506aae919 --- /dev/null +++ b/packages/platform/atoms/calendar-view/CalendarViewComponent.tsx @@ -0,0 +1,31 @@ +import { Header } from "@calcom/features/bookings/Booker/components/Header"; +import { BookerSection } from "@calcom/features/bookings/Booker/components/Section"; +import { LargeCalendar } from "@calcom/features/calendar-view/LargeCalendar"; +import { bookerLayoutOptions } from "@calcom/prisma/zod-utils"; + +import { AtomsWrapper } from "../src/components/atoms-wrapper"; + +export const CalendarViewComponent = () => { + return ( + + +
+ + + + + + ); +}; diff --git a/packages/platform/atoms/calendar-view/EventTypeCalendarViewComponent.tsx b/packages/platform/atoms/calendar-view/EventTypeCalendarViewComponent.tsx new file mode 100644 index 0000000000..fc639eb3bc --- /dev/null +++ b/packages/platform/atoms/calendar-view/EventTypeCalendarViewComponent.tsx @@ -0,0 +1,159 @@ +import { useMemo } from "react"; +import { shallow } from "zustand/shallow"; + +import dayjs from "@calcom/dayjs"; +import { + useBookerStoreContext, + useInitializeBookerStoreContext, +} from "@calcom/features/bookings/Booker/BookerStoreProvider"; +import { Header } from "@calcom/features/bookings/Booker/components/Header"; +import { BookerSection } from "@calcom/features/bookings/Booker/components/Section"; +import { useAvailableTimeSlots } from "@calcom/features/bookings/Booker/components/hooks/useAvailableTimeSlots"; +import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout"; +import { usePrefetch } from "@calcom/features/bookings/Booker/components/hooks/usePrefetch"; +import { useTimePreferences } from "@calcom/features/bookings/lib"; +import { LargeCalendar } from "@calcom/features/calendar-view/LargeCalendar"; +import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents"; +import { useTimesForSchedule } from "@calcom/features/schedules/lib/use-schedule/useTimesForSchedule"; + +import { formatUsername } from "../booker/BookerPlatformWrapper"; +import type { + CalendarViewPlatformWrapperAtomPropsForIndividual, + CalendarViewPlatformWrapperAtomPropsForTeam, +} from "../calendar-view/wrappers/CalendarViewPlatformWrapper"; +import { useAtomGetPublicEvent } from "../hooks/event-types/public/useAtomGetPublicEvent"; +import { useEventType } from "../hooks/event-types/public/useEventType"; +import { useTeamEventType } from "../hooks/event-types/public/useTeamEventType"; +import { useAvailableSlots } from "../hooks/useAvailableSlots"; +import { AtomsWrapper } from "../src/components/atoms-wrapper"; + +export const EventTypeCalendarViewComponent = ( + props: + | (CalendarViewPlatformWrapperAtomPropsForIndividual & { + teamId?: number; + }) + | (CalendarViewPlatformWrapperAtomPropsForTeam & { username?: string | string[] }) +) => { + const isTeamEvent = !!props.teamId; + const teamId: number | undefined = props.teamId ? props.teamId : undefined; + const username = useMemo(() => { + if (props.username) { + return formatUsername(props.username); + } + return ""; + }, [props.username]); + + const { isPending } = useEventType(username, props.eventSlug, isTeamEvent); + + const { isPending: isTeamPending } = useTeamEventType(teamId, props.eventSlug, isTeamEvent); + + const selectedDuration = useBookerStoreContext((state) => state.selectedDuration); + + const event = useAtomGetPublicEvent({ + username, + eventSlug: props.eventSlug, + isTeamEvent: isTeamEvent, + teamId, + selectedDuration, + }); + + const bookerLayout = useBookerLayout(event.data?.profile?.bookerLayouts); + + const [bookerState, _setBookerState] = useBookerStoreContext( + (state) => [state.state, state.setState], + shallow + ); + const selectedDate = useBookerStoreContext((state) => state.selectedDate); + const date = dayjs(selectedDate).format("YYYY-MM-DD"); + const month = useBookerStoreContext((state) => state.month); + const [dayCount] = useBookerStoreContext((state) => [state.dayCount, state.setDayCount], shallow); + + const { prefetchNextMonth, monthCount } = usePrefetch({ + date, + month, + bookerLayout, + bookerState, + }); + + const [startTime, endTime] = useTimesForSchedule({ + month, + monthCount, + dayCount, + prefetchNextMonth, + selectedDate, + }); + + const { timezone } = useTimePreferences(); + const isDynamic = useMemo(() => { + return getUsernameList(username ?? "").length > 1; + }, [username]); + + const bookingData = useBookerStoreContext((state) => state.bookingData); + + useInitializeBookerStoreContext({ + ...props, + eventId: event?.data?.id, + layout: "week_view", + username, + bookingData, + isPlatform: true, + allowUpdatingUrlParams: false, + }); + + const schedule = useAvailableSlots({ + usernameList: getUsernameList(username), + eventTypeId: event?.data?.id ?? 0, + startTime, + endTime, + timeZone: timezone, + duration: selectedDuration ?? undefined, + teamMemberEmail: undefined, + ...(isTeamEvent + ? { + isTeamEvent: isTeamEvent, + teamId: teamId, + } + : {}), + enabled: + Boolean(teamId || username) && + Boolean(month) && + Boolean(timezone) && + (isTeamEvent ? !isTeamPending : !isPending) && + Boolean(event?.data?.id), + orgSlug: undefined, + eventTypeSlug: isDynamic ? "dynamic" : props.eventSlug || "", + }); + + const selectedEventDuration = useBookerStoreContext((state) => state.selectedDuration); + const eventDuration = selectedEventDuration || event?.data?.length || 30; + + const availableTimeSlots = useAvailableTimeSlots({ schedule: schedule.data, eventDuration }); + + return ( + + +
+ + + + + + ); +}; diff --git a/packages/platform/atoms/calendar-view/wrappers/CalendarViewPlatformWrapper.tsx b/packages/platform/atoms/calendar-view/wrappers/CalendarViewPlatformWrapper.tsx index c914a80d01..fcd6c5ff40 100644 --- a/packages/platform/atoms/calendar-view/wrappers/CalendarViewPlatformWrapper.tsx +++ b/packages/platform/atoms/calendar-view/wrappers/CalendarViewPlatformWrapper.tsx @@ -1,166 +1,38 @@ -import { AtomsWrapper } from "@/components/atoms-wrapper"; -import { useMemo } from "react"; -import { shallow } from "zustand/shallow"; +import { BookerStoreProvider } from "@calcom/features/bookings/Booker/BookerStoreProvider"; -import dayjs from "@calcom/dayjs"; -import { - BookerStoreProvider, - useBookerStoreContext, - useInitializeBookerStoreContext, -} from "@calcom/features/bookings/Booker/BookerStoreProvider"; -import { Header } from "@calcom/features/bookings/Booker/components/Header"; -import { BookerSection } from "@calcom/features/bookings/Booker/components/Section"; -import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout"; -import { usePrefetch } from "@calcom/features/bookings/Booker/components/hooks/usePrefetch"; -import { useTimePreferences } from "@calcom/features/bookings/lib"; -import { LargeCalendar } from "@calcom/features/calendar-view/LargeCalendar"; -import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents"; -import { useTimesForSchedule } from "@calcom/features/schedules/lib/use-schedule/useTimesForSchedule"; +import { CalendarViewComponent } from "../CalendarViewComponent"; +import { EventTypeCalendarViewComponent } from "../EventTypeCalendarViewComponent"; -import { formatUsername } from "../../booker/BookerPlatformWrapper"; -import type { - BookerPlatformWrapperAtomPropsForIndividual, - BookerPlatformWrapperAtomPropsForTeam, -} from "../../booker/types"; -import { useAtomGetPublicEvent } from "../../hooks/event-types/public/useAtomGetPublicEvent"; -import { useEventType } from "../../hooks/event-types/public/useEventType"; -import { useTeamEventType } from "../../hooks/event-types/public/useTeamEventType"; -import { useAvailableSlots } from "../../hooks/useAvailableSlots"; - -type CalendarViewPlatformWrapperAtomPropsForIndividual = { +export type CalendarViewPlatformWrapperAtomPropsForIndividual = { username: string | string[]; eventSlug: string; }; -type CalendarViewPlatformWrapperAtomPropsForTeam = { +export type CalendarViewPlatformWrapperAtomPropsForTeam = { teamId: number; eventSlug: string; }; -const CalendarViewPlatformWrapperComponent = ( - props: - | (CalendarViewPlatformWrapperAtomPropsForIndividual & { teamId?: number }) - | (CalendarViewPlatformWrapperAtomPropsForTeam & { username?: string | string[] }) -) => { - const isTeamEvent = !!props.teamId; - const teamId: number | undefined = props.teamId ? props.teamId : undefined; - const username = useMemo(() => { - if (props.username) { - return formatUsername(props.username); - } - return ""; - }, [props.username]); +type EventTypeCalendarViewProps = { isEventTypeView: true } & ( + | (CalendarViewPlatformWrapperAtomPropsForIndividual & { teamId?: number }) + | (CalendarViewPlatformWrapperAtomPropsForTeam & { username?: string | string[] }) +); - const { isPending } = useEventType(username, props.eventSlug, isTeamEvent); - - const { isPending: isTeamPending } = useTeamEventType(teamId, props.eventSlug, isTeamEvent); - - const selectedDuration = useBookerStoreContext((state) => state.selectedDuration); - - const event = useAtomGetPublicEvent({ - username, - eventSlug: props.eventSlug, - isTeamEvent: isTeamEvent, - teamId, - selectedDuration, - }); - - const bookerLayout = useBookerLayout(event.data?.profile?.bookerLayouts); - - const [bookerState, _setBookerState] = useBookerStoreContext( - (state) => [state.state, state.setState], - shallow - ); - const selectedDate = useBookerStoreContext((state) => state.selectedDate); - const date = dayjs(selectedDate).format("YYYY-MM-DD"); - const month = useBookerStoreContext((state) => state.month); - const [dayCount] = useBookerStoreContext((state) => [state.dayCount, state.setDayCount], shallow); - - const { prefetchNextMonth, monthCount } = usePrefetch({ - date, - month, - bookerLayout, - bookerState, - }); - - const [startTime, endTime] = useTimesForSchedule({ - month, - monthCount, - dayCount, - prefetchNextMonth, - selectedDate, - }); - - const { timezone } = useTimePreferences(); - const isDynamic = useMemo(() => { - return getUsernameList(username ?? "").length > 1; - }, [username]); - - const bookingData = useBookerStoreContext((state) => state.bookingData); - - useInitializeBookerStoreContext({ - ...props, - eventId: event?.data?.id, - layout: "week_view", - username, - bookingData, - isPlatform: true, - allowUpdatingUrlParams: false, - }); - - const schedule = useAvailableSlots({ - usernameList: getUsernameList(username), - eventTypeId: event?.data?.id ?? 0, - startTime, - endTime, - timeZone: timezone, - duration: selectedDuration ?? undefined, - teamMemberEmail: undefined, - ...(isTeamEvent - ? { - isTeamEvent: isTeamEvent, - teamId: teamId, - } - : {}), - enabled: - Boolean(teamId || username) && - Boolean(month) && - Boolean(timezone) && - (isTeamEvent ? !isTeamPending : !isPending) && - Boolean(event?.data?.id), - orgSlug: undefined, - eventTypeSlug: isDynamic ? "dynamic" : props.eventSlug || "", - }); - - return ( - - -
- - - - - - ); +type CalendarViewComponentProps = { + isEventTypeView?: false; }; -export const CalendarViewPlatformWrapper = ( - props: - | (BookerPlatformWrapperAtomPropsForIndividual & { teamId?: number }) - | Omit -) => { +type CalendarViewProps = EventTypeCalendarViewProps | CalendarViewComponentProps; + +const CalendarViewPlatformWrapperComponent = (props: CalendarViewProps) => { + if (!props.isEventTypeView) { + return ; + } + + return ; +}; + +export const CalendarViewPlatformWrapper = (props: CalendarViewProps) => { return ( diff --git a/packages/platform/atoms/hooks/useCalendarsBusyTimes.ts b/packages/platform/atoms/hooks/useCalendarsBusyTimes.ts index facf64fd54..3174a56057 100644 --- a/packages/platform/atoms/hooks/useCalendarsBusyTimes.ts +++ b/packages/platform/atoms/hooks/useCalendarsBusyTimes.ts @@ -26,6 +26,29 @@ export const useCalendarsBusyTimes = ({ onError, enabled, ...rest }: UseCalendar return http .get>("/calendars/busy-times", { params: rest, + paramsSerializer: (params) => { + const searchParams = new URLSearchParams(); + + if (params.dateFrom) searchParams.append("dateFrom", params.dateFrom); + if (params.dateTo) searchParams.append("dateTo", params.dateTo); + if (params.loggedInUsersTz) searchParams.append("loggedInUsersTz", params.loggedInUsersTz); + + // calendarsToLoad expects an array of objects but since this is a GET request, we cant pass the data directly inside the body + // hence serializing calendarsToLoad array of objects in the query params + if (params.calendarsToLoad && Array.isArray(params.calendarsToLoad)) { + params.calendarsToLoad.forEach( + (calendar: { credentialId: number; externalId: string }, index: number) => { + searchParams.append( + `calendarsToLoad[${index}][credentialId]`, + String(calendar.credentialId) + ); + searchParams.append(`calendarsToLoad[${index}][externalId]`, calendar.externalId); + } + ); + } + + return searchParams.toString(); + }, }) .then((res) => { if (res.data.status === SUCCESS_STATUS) { diff --git a/packages/platform/examples/base/src/pages/calendar-view.tsx b/packages/platform/examples/base/src/pages/calendar-view.tsx index b80239632b..503d7d8c0d 100644 --- a/packages/platform/examples/base/src/pages/calendar-view.tsx +++ b/packages/platform/examples/base/src/pages/calendar-view.tsx @@ -10,7 +10,8 @@ export default function CalendarViewAtom(props: { calUsername: string; calEmail:
- + {/* */} +
);