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
This commit is contained in:
Rajiv Sahal authored and GitHub committed 2025-12-02 21:44:30 +05:30
1 parent 150e93dc6e
commit 957d19740e
7 files changed
+332 -174

No files matched your search

+5
View File
@@ -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.
@@ -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<BookerEvent, "length" | "id"> | 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 (
<div className="h-full [--calendar-dates-sticky-offset:66px]">
@@ -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 (
<AtomsWrapper>
<BookerSection area="header" className="bg-default dark:bg-muted sticky top-0 z-10">
<Header
isCalendarView={true}
isMyLink={true}
eventSlug="calendar-view"
enabledLayouts={bookerLayoutOptions}
extraDays={7}
isMobile={false}
nextSlots={6}
/>
</BookerSection>
<BookerSection
key="large-calendar"
area="main"
visible={true}
className="border-subtle sticky top-0 ml-px h-full md:border-l">
<LargeCalendar extraDays={7} isLoading={false} />
</BookerSection>
</AtomsWrapper>
);
};
@@ -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 (
<AtomsWrapper>
<BookerSection area="header" className="bg-default dark:bg-muted sticky top-0 z-10">
<Header
isCalendarView={true}
isMyLink={true}
eventSlug={props.eventSlug}
enabledLayouts={bookerLayout.bookerLayouts.enabledLayouts}
extraDays={7}
isMobile={false}
nextSlots={6}
/>
</BookerSection>
<BookerSection
key="large-calendar"
area="main"
visible={true}
className="border-subtle sticky top-0 ml-[-1px] h-full md:border-l">
<LargeCalendar
extraDays={7}
availableTimeslots={availableTimeSlots}
isLoading={schedule.isPending}
event={event}
/>
</BookerSection>
</AtomsWrapper>
);
};
@@ -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 (
<AtomsWrapper>
<BookerSection area="header" className="bg-default dark:bg-cal-muted sticky top-0 z-10">
<Header
isCalendarView={true}
isMyLink={true}
eventSlug={props.eventSlug}
enabledLayouts={bookerLayout.bookerLayouts.enabledLayouts}
extraDays={7}
isMobile={false}
nextSlots={6}
/>
</BookerSection>
<BookerSection
key="large-calendar"
area="main"
visible={true}
className="border-subtle sticky top-0 -ml-px h-full md:border-l">
<LargeCalendar extraDays={7} schedule={schedule.data} isLoading={schedule.isPending} event={event} />
</BookerSection>
</AtomsWrapper>
);
type CalendarViewComponentProps = {
isEventTypeView?: false;
};
export const CalendarViewPlatformWrapper = (
props:
| (BookerPlatformWrapperAtomPropsForIndividual & { teamId?: number })
| Omit<BookerPlatformWrapperAtomPropsForTeam, "isTeamEvent">
) => {
type CalendarViewProps = EventTypeCalendarViewProps | CalendarViewComponentProps;
const CalendarViewPlatformWrapperComponent = (props: CalendarViewProps) => {
if (!props.isEventTypeView) {
return <CalendarViewComponent />;
}
return <EventTypeCalendarViewComponent {...props} />;
};
export const CalendarViewPlatformWrapper = (props: CalendarViewProps) => {
return (
<BookerStoreProvider>
<CalendarViewPlatformWrapperComponent {...props} />
@@ -26,6 +26,29 @@ export const useCalendarsBusyTimes = ({ onError, enabled, ...rest }: UseCalendar
return http
.get<ApiResponse<EventBusyDate[]>>("/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) {
@@ -10,7 +10,8 @@ export default function CalendarViewAtom(props: { calUsername: string; calEmail:
<main className={`flex min-h-screen flex-col ${inter.className}`}>
<Navbar username={props.calUsername} />
<div data-testid="calendars-settings-atom">
<CalendarView username={props.calUsername} eventSlug="sixty-minutes" />
{/* <CalendarView isEventTypeView={true} username={props.calUsername} eventSlug="sixty-minutes" /> */}
<CalendarView />
</div>
</main>
);