feat: calendar view atom v1 (#23840)

* fix: refactor

* add `isMonthViewProp` to header

* feat: init v1 for calendar view atom

* test breaking toggle buttons

* fix: make sure week start is always sunday for calendar view atom

* fixup

* fix: remove extra comments

* fix: add calendar view page in examples app

* chore: add changesets

* fix: coderabbit feedback

* fixup
This commit is contained in:
Rajiv Sahal authored and GitHub committed 2025-09-22 12:17:33 +00:00
1 parent c5942bcfcd
commit 63740c02c7
15 files changed
+413 -56

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": minor
---
This PR adds a new atom called `CalendarView` which is a read only calendar view component for a user.
@@ -5,6 +5,7 @@ import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import dayjs from "@calcom/dayjs";
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useInitializeWeekStart } from "@calcom/features/bookings/Booker/components/hooks/useInitializeWeekStart";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
@@ -25,6 +26,7 @@ export function Header({
eventSlug,
isMyLink,
renderOverlay,
isCalendarView,
}: {
extraDays: number;
isMobile: boolean;
@@ -33,14 +35,16 @@ export function Header({
eventSlug: string;
isMyLink: boolean;
renderOverlay?: () => JSX.Element | null;
isCalendarView?: boolean;
}) {
const { t, i18n } = useLocale();
const isEmbed = useIsEmbed();
const isPlatform = useIsPlatform();
const [layout, setLayout] = useBookerStoreContext((state) => [state.layout, state.setLayout], shallow);
const selectedDateString = useBookerStoreContext((state) => state.selectedDate);
const setSelectedDate = useBookerStoreContext((state) => state.setSelectedDate);
const addToSelectedDate = useBookerStoreContext((state) => state.addToSelectedDate);
const isMonthView = layout === BookerLayouts.MONTH_VIEW;
const isMonthView = isCalendarView !== undefined ? !isCalendarView : layout === BookerLayouts.MONTH_VIEW;
const today = dayjs();
const selectedDate = selectedDateString ? dayjs(selectedDateString) : today;
const selectedDateMin3DaysDifference = useMemo(() => {
@@ -48,6 +52,8 @@ export function Header({
return diff > 3 || diff < -3;
}, [today, selectedDate]);
useInitializeWeekStart(isPlatform, isCalendarView ?? false);
const onLayoutToggle = useCallback(
(newLayout: string) => {
if (layout === newLayout || !newLayout) return;
@@ -130,7 +136,10 @@ export function Header({
<Button
className="capitalize ltr:ml-2 rtl:mr-2"
color="secondary"
onClick={() => setSelectedDate({ date: today.format("YYYY-MM-DD") })}>
onClick={() => {
const selectedDate = (isCalendarView ? today.startOf("week") : today).format("YYYY-MM-DD");
setSelectedDate({ date: selectedDate });
}}>
{t("today")}
</Button>
)}
@@ -2,10 +2,10 @@ import { useMemo, useEffect } 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 type { BookerEvent } from "@calcom/features/bookings/types";
import { Calendar } from "@calcom/features/calendars/weeklyview";
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/weeklyview/types/state";
import { localStorage } from "@calcom/lib/webstorage";
import type { useScheduleForEventReturnType } from "../utils/event";
@@ -34,24 +34,7 @@ export const LargeCalendar = ({
const eventDuration = selectedEventDuration || event?.data?.length || 30;
const availableSlots = useMemo(() => {
const availableTimeslots: CalendarAvailableTimeslots = {};
if (!schedule) return availableTimeslots;
if (!schedule.slots) return availableTimeslots;
for (const day in schedule.slots) {
availableTimeslots[day] = schedule.slots[day].map((slot) => {
const { time, ...rest } = slot;
return {
start: dayjs(time).toDate(),
end: dayjs(time).add(eventDuration, "minutes").toDate(),
...rest,
};
});
}
return availableTimeslots;
}, [schedule, eventDuration]);
const availableSlots = useAvailableTimeSlots({ schedule, eventDuration });
const startDate = selectedDate ? dayjs(selectedDate).toDate() : dayjs().toDate();
const endDate = dayjs(startDate)
@@ -0,0 +1,30 @@
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/weeklyview/types/state";
import type { IGetAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
interface UseAvailableTimeSlotsProps {
eventDuration: number;
schedule?: IGetAvailableSlots;
}
export const useAvailableTimeSlots = ({ schedule, eventDuration }: UseAvailableTimeSlotsProps) => {
return useMemo(() => {
const availableTimeslots: CalendarAvailableTimeslots = {};
if (!schedule || !schedule.slots) return availableTimeslots;
for (const day in schedule.slots) {
availableTimeslots[day] = schedule.slots[day].map((slot) => {
const { time, ...rest } = slot;
return {
start: dayjs(time).toDate(),
end: dayjs(time).add(eventDuration, "minutes").toDate(),
...rest,
};
});
}
return availableTimeslots;
}, [schedule, eventDuration]);
};
@@ -0,0 +1,17 @@
import { useEffect } from "react";
import dayjs from "@calcom/dayjs";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
export const useInitializeWeekStart = (isPlatform: boolean, isCalendarView: boolean) => {
const today = dayjs();
const weekStart = today.startOf("week").format("YYYY-MM-DD");
const setSelectedDate = useBookerStoreContext((state) => state.setSelectedDate);
useEffect(() => {
if (isPlatform && isCalendarView) {
setSelectedDate({ date: weekStart, omitUpdatingParams: true });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
};
@@ -0,0 +1,79 @@
import { useMemo, useEffect } 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 type { BookerEvent } from "@calcom/features/bookings/types";
import { Calendar } from "@calcom/features/calendars/weeklyview";
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
import { localStorage } from "@calcom/lib/webstorage";
import { useOverlayCalendarStore } from "../bookings/Booker/components/OverlayCalendar/store";
import type { useScheduleForEventReturnType } from "../bookings/Booker/utils/event";
import { getQueryParam } from "../bookings/Booker/utils/query-param";
export const LargeCalendar = ({
extraDays,
schedule,
isLoading,
event,
}: {
extraDays: number;
schedule?: useScheduleForEventReturnType["data"];
isLoading: boolean;
event: {
data?: Pick<BookerEvent, "length"> | null;
};
}) => {
const selectedDate = useBookerStoreContext((state) => state.selectedDate);
const selectedEventDuration = useBookerStoreContext((state) => state.selectedDuration);
const overlayEvents = useOverlayCalendarStore((state) => state.overlayBusyDates);
const displayOverlay =
getQueryParam("overlayCalendar") === "true" || localStorage?.getItem("overlayCalendarSwitchDefault");
const eventDuration = selectedEventDuration || event?.data?.length || 30;
const availableSlots = useAvailableTimeSlots({ schedule, eventDuration });
const startDate = selectedDate ? dayjs(selectedDate).toDate() : dayjs().toDate();
const endDate = dayjs(startDate)
.add(extraDays - 1, "day")
.toDate();
// HACK: force rerender when overlay events change
// Sine we dont use react router here we need to force rerender (ATOM SUPPORT)
// eslint-disable-next-line @typescript-eslint/no-empty-function
useEffect(() => {}, [displayOverlay]);
const overlayEventsForDate = useMemo(() => {
if (!overlayEvents || !displayOverlay) return [];
return overlayEvents.map((event, id) => {
return {
id,
start: dayjs(event.start).toDate(),
end: dayjs(event.end).toDate(),
title: "Busy",
options: {
status: "ACCEPTED",
},
} as CalendarEvent;
});
}, [overlayEvents, displayOverlay]);
return (
<div className="h-full [--calendar-dates-sticky-offset:66px]">
<Calendar
isPending={isLoading}
availableTimeslots={availableSlots}
startHour={0}
endHour={23}
events={overlayEventsForDate}
startDate={startDate}
endDate={endDate}
gridCellsPerHour={60 / eventDuration}
hoverEventDuration={eventDuration}
hideHeader
/>
</div>
);
};
@@ -2,8 +2,8 @@ import { useSession } from "next-auth/react";
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { useAvailableTimeSlots } from "@calcom/features/bookings/Booker/components/hooks/useAvailableTimeSlots";
import { Calendar } from "@calcom/features/calendars/weeklyview";
import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/weeklyview/types/state";
import { BookingStatus } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
@@ -48,22 +48,7 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
.add(extraDays - 1, "day")
.toDate();
const availableSlots = useMemo(() => {
const availableTimeslots: CalendarAvailableTimeslots = {};
if (!schedule) return availableTimeslots;
if (!schedule?.slots) return availableTimeslots;
for (const day in schedule.slots) {
availableTimeslots[day] = schedule.slots[day].map((slot) => ({
start: dayjs(slot.time).toDate(),
end: dayjs(slot.time)
.add(event?.duration ?? 30, "minutes")
.toDate(),
}));
}
return availableTimeslots;
}, [schedule, event]);
const availableSlots = useAvailableTimeSlots({ schedule, eventDuration: event?.duration ?? 30 });
const events = useMemo(() => {
if (!busyEvents?.busy) return [];
@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useQueryClient } from "@tanstack/react-query";
// eslint-disable-next-line no-restricted-imports
import debounce from "lodash/debounce";
@@ -594,7 +595,7 @@ export const BookerPlatformWrapper = (
);
};
function formatUsername(username: string | string[]): string {
export function formatUsername(username: string | string[]): string {
if (typeof username === "string") {
return username;
}
@@ -0,0 +1 @@
export { CalendarViewPlatformWrapper } from "./wrappers/CalendarViewPlatformWrapper";
@@ -0,0 +1,223 @@
import { AtomsWrapper } from "@/components/atoms-wrapper";
import { useMemo, useEffect, useState } from "react";
import { shallow } from "zustand/shallow";
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 { 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 { getRoutedTeamMemberIdsFromSearchParams } from "@calcom/lib/bookings/getRoutedTeamMemberIdsFromSearchParams";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
import { formatUsername } from "../../booker/BookerPlatformWrapper";
import type {
BookerPlatformWrapperAtomPropsForIndividual,
BookerPlatformWrapperAtomPropsForTeam,
} from "../../booker/types";
import { useGetBookingForReschedule } from "../../hooks/bookings/useGetBookingForReschedule";
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";
const CalendarViewPlatformWrapperComponent = (
props: BookerPlatformWrapperAtomPropsForIndividual | BookerPlatformWrapperAtomPropsForTeam
) => {
const {
eventSlug,
isTeamEvent,
hostsLimit,
allowUpdatingUrlParams = false,
teamMemberEmail,
crmAppSlug,
crmOwnerRecordType,
view = "MONTH_VIEW",
} = props;
const teamId: number | undefined = props.isTeamEvent ? props.teamId : undefined;
const username = useMemo(() => {
if (props.username) {
return formatUsername(props.username);
}
return "";
}, [props.username]);
const { isPending } = useEventType(username, eventSlug, isTeamEvent);
const { isPending: isTeamPending } = useTeamEventType(teamId, eventSlug, isTeamEvent, hostsLimit);
const setSelectedDuration = useBookerStoreContext((state) => state.setSelectedDuration);
const selectedDuration = useBookerStoreContext((state) => state.selectedDuration);
const event = useAtomGetPublicEvent({
username,
eventSlug: props.eventSlug,
isTeamEvent: props.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 monthCount =
((bookerLayout.layout !== BookerLayouts.WEEK_VIEW && bookerState === "selecting_time") ||
bookerLayout.layout === BookerLayouts.COLUMN_VIEW) &&
dayjs(date).add(1, "month").month() !==
dayjs(date).add(bookerLayout.columnViewExtraDays.current, "day").month()
? 2
: undefined;
const month = useBookerStoreContext((state) => state.month);
const [dayCount] = useBookerStoreContext((state) => [state.dayCount, state.setDayCount], shallow);
const prefetchNextMonth =
(bookerLayout.layout === BookerLayouts.WEEK_VIEW &&
!!bookerLayout.extraDays &&
dayjs(date).month() !== dayjs(date).add(bookerLayout.extraDays, "day").month()) ||
(bookerLayout.layout === BookerLayouts.COLUMN_VIEW &&
dayjs(date).month() !== dayjs(date).add(bookerLayout.columnViewExtraDays.current, "day").month());
const [startTime, endTime] = useTimesForSchedule({
month,
monthCount,
dayCount,
prefetchNextMonth,
selectedDate,
});
useEffect(() => {
setSelectedDuration(props.duration ?? null);
}, [props.duration, setSelectedDuration]);
const { timezone } = useTimePreferences();
const isDynamic = useMemo(() => {
return getUsernameList(username ?? "").length > 1;
}, [username]);
const [routingParams, setRoutingParams] = useState<{
routedTeamMemberIds?: number[];
_shouldServeCache?: boolean;
skipContactOwner?: boolean;
isBookingDryRun?: boolean;
}>({});
useEffect(() => {
const searchParams = props.routingFormSearchParams
? new URLSearchParams(props.routingFormSearchParams)
: new URLSearchParams(window.location.search);
const routedTeamMemberIds = getRoutedTeamMemberIdsFromSearchParams(searchParams);
const skipContactOwner = searchParams.get("cal.skipContactOwner") === "true";
const _cacheParam = searchParams?.get("cal.cache");
const _shouldServeCache = _cacheParam ? _cacheParam === "true" : undefined;
const isBookingDryRun =
searchParams?.get("cal.isBookingDryRun")?.toLowerCase() === "true" ||
searchParams?.get("cal.sandbox")?.toLowerCase() === "true";
setRoutingParams({
...(skipContactOwner ? { skipContactOwner } : {}),
...(routedTeamMemberIds ? { routedTeamMemberIds } : {}),
...(_shouldServeCache ? { _shouldServeCache } : {}),
...(isBookingDryRun ? { isBookingDryRun } : {}),
});
}, [props.routingFormSearchParams]);
const bookingData = useBookerStoreContext((state) => state.bookingData);
const setBookingData = useBookerStoreContext((state) => state.setBookingData);
const layout = BookerLayouts[view];
useGetBookingForReschedule({
uid: props.rescheduleUid ?? props.bookingUid ?? "",
onSuccess: (data) => {
setBookingData(data);
},
});
useInitializeBookerStoreContext({
...props,
teamMemberEmail,
crmAppSlug,
crmOwnerRecordType,
crmRecordId: props.crmRecordId,
eventId: event?.data?.id,
rescheduleUid: props.rescheduleUid ?? null,
bookingUid: props.bookingUid ?? null,
layout: layout,
org: props.entity?.orgSlug,
username,
bookingData,
isPlatform: true,
allowUpdatingUrlParams,
});
const schedule = useAvailableSlots({
usernameList: getUsernameList(username),
eventTypeId: event?.data?.id ?? 0,
startTime,
endTime,
timeZone: timezone,
duration: selectedDuration ?? undefined,
rescheduleUid: props.rescheduleUid,
teamMemberEmail: props.teamMemberEmail ?? undefined,
...(props.isTeamEvent
? {
isTeamEvent: props.isTeamEvent,
teamId: teamId,
}
: {}),
enabled:
Boolean(teamId || username) &&
Boolean(month) &&
Boolean(timezone) &&
(props.isTeamEvent ? !isTeamPending : !isPending) &&
Boolean(event?.data?.id),
orgSlug: props.entity?.orgSlug ?? undefined,
eventTypeSlug: isDynamic ? "dynamic" : eventSlug || "",
...routingParams,
});
return (
<AtomsWrapper>
<BookerSection area="header" className="bg-default dark:bg-muted sticky top-0 z-10">
<Header
isCalendarView={true}
isMyLink={true}
eventSlug={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} schedule={schedule.data} isLoading={schedule.isPending} event={event} />
</BookerSection>
</AtomsWrapper>
);
};
export const CalendarViewPlatformWrapper = (
props: BookerPlatformWrapperAtomPropsForIndividual | BookerPlatformWrapperAtomPropsForTeam
) => {
return (
<BookerStoreProvider>
<CalendarViewPlatformWrapperComponent {...props} />
</BookerStoreProvider>
);
};
@@ -3,6 +3,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { useRef, useState, useEffect, forwardRef, useImperativeHandle, useCallback } from "react";
import { BookerStoreProvider } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import { EventType as EventTypeComponent } from "@calcom/features/eventtypes/components/EventType";
import ManagedEventTypeDialog from "@calcom/features/eventtypes/components/dialogs/ManagedEventDialog";
@@ -417,20 +418,22 @@ export const EventTypePlatformWrapper = forwardRef<
if (!eventTypeQueryData) return null;
return (
<EventType
{...eventTypeQueryData}
id={id}
tabs={tabs}
onSuccess={onSuccess}
onError={onError}
onDeleteSuccess={onDeleteSuccess}
onDeleteError={onDeleteError}
allowDelete={allowDelete}
customClassNames={customClassNames}
isDryRun={isDryRun}
onFormStateChange={onFormStateChange}
ref={ref}
disableToasts={disableToasts}
/>
<BookerStoreProvider>
<EventType
{...eventTypeQueryData}
id={id}
tabs={tabs}
onSuccess={onSuccess}
onError={onError}
onDeleteSuccess={onDeleteSuccess}
onDeleteError={onDeleteError}
allowDelete={allowDelete}
customClassNames={customClassNames}
isDryRun={isDryRun}
onFormStateChange={onFormStateChange}
ref={ref}
disableToasts={disableToasts}
/>
</BookerStoreProvider>
);
});
+1
View File
@@ -41,3 +41,4 @@ export { useCreateTeamEventType } from "./hooks/event-types/private/useCreateTea
export { useOrganizationBookings } from "./hooks/organizations/bookings/useOrganizationBookings";
export { useOrganizationUserBookings } from "./hooks/organizations/bookings/useOrganizationUserBookings";
export { CalendarViewPlatformWrapper as CalendarView } from "./calendar-view/index";
@@ -15,12 +15,12 @@ import { Dropdown, DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui/c
import { Switch } from "@calcom/ui/components/form";
import { List } from "@calcom/ui/components/list";
import * as Connect from "../../connect";
import { AppleConnect } from "../../connect/apple/AppleConnect";
import { useAddSelectedCalendar } from "../../hooks/calendars/useAddSelectedCalendar";
import { useDeleteCalendarCredentials } from "../../hooks/calendars/useDeleteCalendarCredentials";
import { useRemoveSelectedCalendar } from "../../hooks/calendars/useRemoveSelectedCalendar";
import { useConnectedCalendars } from "../../hooks/useConnectedCalendars";
import { Connect } from "../../index";
import { AtomsWrapper } from "../../src/components/atoms-wrapper";
import { useToast } from "../../src/components/ui/use-toast";
import { SelectedCalendarsSettings } from "../SelectedCalendarsSettings";
@@ -3,6 +3,7 @@ import Link from "next/link";
const poppins = Poppins({ subsets: ["latin"], weight: ["400", "800"] });
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function Navbar({ username }: { username?: string }) {
return (
<nav className="flex h-[75px] w-[100%] items-center justify-between bg-black px-14 py-3 text-white">
@@ -13,9 +14,11 @@ export function Navbar({ username }: { username?: string }) {
</h1>
</Link>
</div>
{username && <div className="capitalize">👤 {username}</div>}
<div className={`${poppins.className}`}>
<ul className="flex gap-x-7">
<li>
<Link href="/calendar-view">Week View</Link>
</li>
<li>
<Link href="/calendars">Calendar</Link>
</li>
@@ -0,0 +1,17 @@
import { Navbar } from "@/components/Navbar";
import { Inter } from "next/font/google";
import { CalendarView } from "@calcom/atoms";
const inter = Inter({ subsets: ["latin"] });
export default function CalendarViewAtom(props: { calUsername: string; calEmail: string }) {
return (
<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" />
</div>
</main>
);
}