Skip to content

Commit 957d197

Browse files
authored
feat: CalendarView atom v2 (calcom#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
1 parent 150e93d commit 957d197

7 files changed

Lines changed: 330 additions & 172 deletions

File tree

.changeset/clear-webs-study.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@calcom/atoms": minor
3+
---
4+
5+
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.

packages/features/calendar-view/LargeCalendar.tsx

Lines changed: 91 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,28 @@ import { useEffect, useMemo } from "react";
22

33
import dayjs from "@calcom/dayjs";
44
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
5-
import { useAvailableTimeSlots } from "@calcom/features/bookings/Booker/components/hooks/useAvailableTimeSlots";
65
import { useBookerTime } from "@calcom/features/bookings/Booker/components/hooks/useBookerTime";
76
import type { BookerEvent } from "@calcom/features/bookings/types";
87
import { Calendar } from "@calcom/features/calendars/weeklyview";
8+
import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/weeklyview/types/state";
99
import { localStorage } from "@calcom/lib/webstorage";
1010
import type { BookingStatus } from "@calcom/prisma/enums";
1111

1212
import { useBookings } from "../../platform/atoms/hooks/bookings/useBookings";
13-
import type { useScheduleForEventReturnType } from "../bookings/Booker/utils/event";
13+
import { useCalendarsBusyTimes } from "../../platform/atoms/hooks/useCalendarsBusyTimes";
14+
import { useConnectedCalendars } from "../../platform/atoms/hooks/useConnectedCalendars";
1415
import { getQueryParam } from "../bookings/Booker/utils/query-param";
1516

1617
export const LargeCalendar = ({
1718
extraDays,
18-
schedule,
19+
availableTimeslots,
1920
isLoading,
2021
event,
2122
}: {
2223
extraDays: number;
23-
schedule?: useScheduleForEventReturnType["data"];
24+
availableTimeslots?: CalendarAvailableTimeslots | undefined;
2425
isLoading: boolean;
25-
event: {
26+
event?: {
2627
data?: Pick<BookerEvent, "length" | "id"> | null;
2728
};
2829
}) => {
@@ -34,44 +35,110 @@ export const LargeCalendar = ({
3435

3536
const eventDuration = selectedEventDuration || event?.data?.length || 30;
3637

37-
const availableSlots = useAvailableTimeSlots({ schedule, eventDuration });
38+
const availableSlots = availableTimeslots !== undefined ? availableTimeslots : undefined;
3839

3940
const startDate = selectedDate ? dayjs(selectedDate).toDate() : dayjs().toDate();
4041
const endDate = dayjs(startDate)
4142
.add(extraDays - 1, "day")
4243
.toDate();
4344

44-
const { data: upcomingBookings } = useBookings({
45+
const { data: bookings, isPending: _isFetchingBookings } = useBookings({
4546
take: 150,
4647
skip: 0,
47-
status: ["upcoming", "past", "recurring"],
4848
eventTypeId: event?.data?.id,
4949
afterStart: startDate.toISOString(),
5050
beforeEnd: endDate.toISOString(),
51+
status: ["upcoming", "past", "recurring"],
52+
});
53+
54+
const { data: connectedCalendars, isPending: isFetchingConnectedCalendars } = useConnectedCalendars({
55+
enabled: true,
56+
});
57+
58+
const calendarsToLoad = connectedCalendars?.connectedCalendars.flatMap((connectedCalendar) => {
59+
return (
60+
connectedCalendar.calendars
61+
?.filter((calendar) => calendar.isSelected === true)
62+
.map((cal) => ({
63+
credentialId: cal.credentialId,
64+
externalId: cal.externalId,
65+
})) ?? []
66+
);
67+
});
68+
69+
const { data: overlayBusyDates } = useCalendarsBusyTimes({
70+
loggedInUsersTz: timezone,
71+
dateFrom: startDate.toISOString(),
72+
dateTo: endDate.toISOString(),
73+
calendarsToLoad: calendarsToLoad ?? [],
74+
enabled: Boolean(!isFetchingConnectedCalendars && !!calendarsToLoad?.length),
5175
});
5276

5377
// HACK: force rerender when overlay events change
5478
// Sine we dont use react router here we need to force rerender (ATOM SUPPORT)
5579

5680
useEffect(() => {}, [displayOverlay]);
5781

58-
const overlayEventsForDate = useMemo(() => {
59-
if (!upcomingBookings) return [];
60-
61-
return upcomingBookings?.map((booking) => {
62-
return {
63-
id: booking.id,
64-
title: booking.title ?? `Busy`,
65-
start: new Date(booking.start),
66-
end: new Date(booking.end),
67-
options: {
68-
status: booking.status.toUpperCase() as BookingStatus,
69-
"data-test-id": "troubleshooter-busy-event",
70-
className: "border-[1.5px]",
71-
},
72-
};
82+
const sortedBookingRanges = useMemo(
83+
() =>
84+
(bookings ?? [])
85+
.map((booking) => ({
86+
booking,
87+
startTime: dayjs(booking.start).valueOf(),
88+
endTime: dayjs(booking.end).valueOf(),
89+
}))
90+
.sort((a, b) => a.startTime - b.startTime),
91+
[bookings]
92+
);
93+
94+
const filteredBusyDates = useMemo(() => {
95+
const overlay = overlayBusyDates?.data ?? [];
96+
if (!sortedBookingRanges.length) return overlay;
97+
98+
return overlay.filter((busy) => {
99+
const busyStart = dayjs(busy.start).valueOf();
100+
const busyEnd = dayjs(busy.end).valueOf();
101+
102+
for (const { startTime, endTime } of sortedBookingRanges) {
103+
if (startTime >= busyEnd) break;
104+
if (endTime <= busyStart) continue;
105+
return false;
106+
}
107+
108+
return true;
73109
});
74-
}, [upcomingBookings]);
110+
}, [overlayBusyDates?.data, sortedBookingRanges]);
111+
112+
const overlayEventsForDate = useMemo(() => {
113+
const allBookings = bookings ?? [];
114+
// 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
115+
// hence we filter overlayBusyDates to exclude anything that overlaps with bookings
116+
const busyEvents = filteredBusyDates.map((busyData, index) => ({
117+
id: index,
118+
title: `Busy`,
119+
start: new Date(busyData.start),
120+
end: new Date(busyData.end),
121+
options: {
122+
status: "ACCEPTED" as BookingStatus,
123+
"data-test-id": "troubleshooter-busy-event",
124+
className: "border-[1.5px]",
125+
},
126+
}));
127+
128+
const bookingEvents = allBookings.map((booking) => ({
129+
id: booking.id,
130+
title: booking.title ?? `Busy`,
131+
start: new Date(booking.start),
132+
end: new Date(booking.end),
133+
options: {
134+
status: booking.status.toUpperCase() as BookingStatus,
135+
"data-test-id": "troubleshooter-busy-event",
136+
className: "border-[1.5px]",
137+
},
138+
}));
139+
140+
return [...bookingEvents, ...busyEvents];
141+
}, [bookings, filteredBusyDates]);
75142

76143
return (
77144
<div className="h-full [--calendar-dates-sticky-offset:66px]">
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Header } from "@calcom/features/bookings/Booker/components/Header";
2+
import { BookerSection } from "@calcom/features/bookings/Booker/components/Section";
3+
import { LargeCalendar } from "@calcom/features/calendar-view/LargeCalendar";
4+
import { bookerLayoutOptions } from "@calcom/prisma/zod-utils";
5+
6+
import { AtomsWrapper } from "../src/components/atoms-wrapper";
7+
8+
export const CalendarViewComponent = () => {
9+
return (
10+
<AtomsWrapper>
11+
<BookerSection area="header" className="bg-default dark:bg-muted sticky top-0 z-10">
12+
<Header
13+
isCalendarView={true}
14+
isMyLink={true}
15+
eventSlug="calendar-view"
16+
enabledLayouts={bookerLayoutOptions}
17+
extraDays={7}
18+
isMobile={false}
19+
nextSlots={6}
20+
/>
21+
</BookerSection>
22+
<BookerSection
23+
key="large-calendar"
24+
area="main"
25+
visible={true}
26+
className="border-subtle sticky top-0 ml-px h-full md:border-l">
27+
<LargeCalendar extraDays={7} isLoading={false} />
28+
</BookerSection>
29+
</AtomsWrapper>
30+
);
31+
};
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { useMemo } from "react";
2+
import { shallow } from "zustand/shallow";
3+
4+
import dayjs from "@calcom/dayjs";
5+
import {
6+
useBookerStoreContext,
7+
useInitializeBookerStoreContext,
8+
} from "@calcom/features/bookings/Booker/BookerStoreProvider";
9+
import { Header } from "@calcom/features/bookings/Booker/components/Header";
10+
import { BookerSection } from "@calcom/features/bookings/Booker/components/Section";
11+
import { useAvailableTimeSlots } from "@calcom/features/bookings/Booker/components/hooks/useAvailableTimeSlots";
12+
import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout";
13+
import { usePrefetch } from "@calcom/features/bookings/Booker/components/hooks/usePrefetch";
14+
import { useTimePreferences } from "@calcom/features/bookings/lib";
15+
import { LargeCalendar } from "@calcom/features/calendar-view/LargeCalendar";
16+
import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents";
17+
import { useTimesForSchedule } from "@calcom/features/schedules/lib/use-schedule/useTimesForSchedule";
18+
19+
import { formatUsername } from "../booker/BookerPlatformWrapper";
20+
import type {
21+
CalendarViewPlatformWrapperAtomPropsForIndividual,
22+
CalendarViewPlatformWrapperAtomPropsForTeam,
23+
} from "../calendar-view/wrappers/CalendarViewPlatformWrapper";
24+
import { useAtomGetPublicEvent } from "../hooks/event-types/public/useAtomGetPublicEvent";
25+
import { useEventType } from "../hooks/event-types/public/useEventType";
26+
import { useTeamEventType } from "../hooks/event-types/public/useTeamEventType";
27+
import { useAvailableSlots } from "../hooks/useAvailableSlots";
28+
import { AtomsWrapper } from "../src/components/atoms-wrapper";
29+
30+
export const EventTypeCalendarViewComponent = (
31+
props:
32+
| (CalendarViewPlatformWrapperAtomPropsForIndividual & {
33+
teamId?: number;
34+
})
35+
| (CalendarViewPlatformWrapperAtomPropsForTeam & { username?: string | string[] })
36+
) => {
37+
const isTeamEvent = !!props.teamId;
38+
const teamId: number | undefined = props.teamId ? props.teamId : undefined;
39+
const username = useMemo(() => {
40+
if (props.username) {
41+
return formatUsername(props.username);
42+
}
43+
return "";
44+
}, [props.username]);
45+
46+
const { isPending } = useEventType(username, props.eventSlug, isTeamEvent);
47+
48+
const { isPending: isTeamPending } = useTeamEventType(teamId, props.eventSlug, isTeamEvent);
49+
50+
const selectedDuration = useBookerStoreContext((state) => state.selectedDuration);
51+
52+
const event = useAtomGetPublicEvent({
53+
username,
54+
eventSlug: props.eventSlug,
55+
isTeamEvent: isTeamEvent,
56+
teamId,
57+
selectedDuration,
58+
});
59+
60+
const bookerLayout = useBookerLayout(event.data?.profile?.bookerLayouts);
61+
62+
const [bookerState, _setBookerState] = useBookerStoreContext(
63+
(state) => [state.state, state.setState],
64+
shallow
65+
);
66+
const selectedDate = useBookerStoreContext((state) => state.selectedDate);
67+
const date = dayjs(selectedDate).format("YYYY-MM-DD");
68+
const month = useBookerStoreContext((state) => state.month);
69+
const [dayCount] = useBookerStoreContext((state) => [state.dayCount, state.setDayCount], shallow);
70+
71+
const { prefetchNextMonth, monthCount } = usePrefetch({
72+
date,
73+
month,
74+
bookerLayout,
75+
bookerState,
76+
});
77+
78+
const [startTime, endTime] = useTimesForSchedule({
79+
month,
80+
monthCount,
81+
dayCount,
82+
prefetchNextMonth,
83+
selectedDate,
84+
});
85+
86+
const { timezone } = useTimePreferences();
87+
const isDynamic = useMemo(() => {
88+
return getUsernameList(username ?? "").length > 1;
89+
}, [username]);
90+
91+
const bookingData = useBookerStoreContext((state) => state.bookingData);
92+
93+
useInitializeBookerStoreContext({
94+
...props,
95+
eventId: event?.data?.id,
96+
layout: "week_view",
97+
username,
98+
bookingData,
99+
isPlatform: true,
100+
allowUpdatingUrlParams: false,
101+
});
102+
103+
const schedule = useAvailableSlots({
104+
usernameList: getUsernameList(username),
105+
eventTypeId: event?.data?.id ?? 0,
106+
startTime,
107+
endTime,
108+
timeZone: timezone,
109+
duration: selectedDuration ?? undefined,
110+
teamMemberEmail: undefined,
111+
...(isTeamEvent
112+
? {
113+
isTeamEvent: isTeamEvent,
114+
teamId: teamId,
115+
}
116+
: {}),
117+
enabled:
118+
Boolean(teamId || username) &&
119+
Boolean(month) &&
120+
Boolean(timezone) &&
121+
(isTeamEvent ? !isTeamPending : !isPending) &&
122+
Boolean(event?.data?.id),
123+
orgSlug: undefined,
124+
eventTypeSlug: isDynamic ? "dynamic" : props.eventSlug || "",
125+
});
126+
127+
const selectedEventDuration = useBookerStoreContext((state) => state.selectedDuration);
128+
const eventDuration = selectedEventDuration || event?.data?.length || 30;
129+
130+
const availableTimeSlots = useAvailableTimeSlots({ schedule: schedule.data, eventDuration });
131+
132+
return (
133+
<AtomsWrapper>
134+
<BookerSection area="header" className="bg-default dark:bg-muted sticky top-0 z-10">
135+
<Header
136+
isCalendarView={true}
137+
isMyLink={true}
138+
eventSlug={props.eventSlug}
139+
enabledLayouts={bookerLayout.bookerLayouts.enabledLayouts}
140+
extraDays={7}
141+
isMobile={false}
142+
nextSlots={6}
143+
/>
144+
</BookerSection>
145+
<BookerSection
146+
key="large-calendar"
147+
area="main"
148+
visible={true}
149+
className="border-subtle sticky top-0 ml-[-1px] h-full md:border-l">
150+
<LargeCalendar
151+
extraDays={7}
152+
availableTimeslots={availableTimeSlots}
153+
isLoading={schedule.isPending}
154+
event={event}
155+
/>
156+
</BookerSection>
157+
</AtomsWrapper>
158+
);
159+
};

0 commit comments

Comments
 (0)