From 2d28ca61a4db5a8314c7b044b36a66cdcc26b148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20L=C3=B3pez?= Date: Sun, 19 Jun 2022 09:02:00 -0600 Subject: [PATCH] Feature/parallel booking availability (#3087) --- apps/web/components/I18nLanguageHandler.tsx | 2 +- apps/web/components/auth/SAMLLogin.tsx | 2 +- .../booking/pages/AvailabilityPage.tsx | 77 +++++++++++++------ .../security/DisableUserImpersonation.tsx | 2 +- apps/web/lib/QueryCell.tsx | 8 +- apps/web/lib/app-providers.tsx | 4 +- apps/web/lib/isOutOfBounds.tsx | 3 + apps/web/package.json | 8 +- apps/web/pages/[user]/[type].tsx | 38 +++++---- apps/web/pages/_app.tsx | 22 +++++- apps/web/pages/api/book/request-reschedule.ts | 8 +- apps/web/pages/api/cancel.ts | 2 +- apps/web/pages/api/trpc/[trpc].ts | 22 +++++- apps/web/pages/settings/profile.tsx | 4 +- apps/web/server/lib/ssg.ts | 2 +- apps/web/server/lib/ssr.ts | 4 +- apps/web/server/routers/viewer.tsx | 8 +- apps/web/server/routers/viewer/slots.tsx | 35 ++++++--- packages/core/CalendarManager.ts | 50 +++++++++--- packages/ui/booker/DatePicker.tsx | 21 ++--- yarn.lock | 41 +++++----- 21 files changed, 242 insertions(+), 121 deletions(-) diff --git a/apps/web/components/I18nLanguageHandler.tsx b/apps/web/components/I18nLanguageHandler.tsx index 20bfc597c7..591e7c712a 100644 --- a/apps/web/components/I18nLanguageHandler.tsx +++ b/apps/web/components/I18nLanguageHandler.tsx @@ -4,7 +4,7 @@ import { useEffect } from "react"; import { trpc } from "@lib/trpc"; export function useViewerI18n() { - return trpc.useQuery(["viewer.i18n"], { + return trpc.useQuery(["viewer.public.i18n"], { staleTime: Infinity, }); } diff --git a/apps/web/components/auth/SAMLLogin.tsx b/apps/web/components/auth/SAMLLogin.tsx index b1d86ca902..8ada1fa96e 100644 --- a/apps/web/components/auth/SAMLLogin.tsx +++ b/apps/web/components/auth/SAMLLogin.tsx @@ -21,7 +21,7 @@ export default function SAMLLogin(props: Props) { const methods = useFormContext(); const telemetry = useTelemetry(); - const mutation = trpc.useMutation("viewer.samlTenantProduct", { + const mutation = trpc.useMutation("viewer.public.samlTenantProduct", { onSuccess: async (data) => { await signIn("saml", {}, { tenant: data.tenant, product: data.product }); }, diff --git a/apps/web/components/booking/pages/AvailabilityPage.tsx b/apps/web/components/booking/pages/AvailabilityPage.tsx index ddee5ef675..a522d92771 100644 --- a/apps/web/components/booking/pages/AvailabilityPage.tsx +++ b/apps/web/components/booking/pages/AvailabilityPage.tsx @@ -36,7 +36,7 @@ import { yyyymmdd } from "@calcom/lib/date-fns"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { getRecurringFreq } from "@calcom/lib/recurringStrings"; import { localStorage } from "@calcom/lib/webstorage"; -import DatePicker from "@calcom/ui/booker/DatePicker"; +import DatePicker, { Day } from "@calcom/ui/booker/DatePicker"; import { asStringOrNull } from "@lib/asStringOrNull"; import { timeZone } from "@lib/clock"; @@ -54,8 +54,6 @@ import { HeadSeo } from "@components/seo/head-seo"; import AvatarGroup from "@components/ui/AvatarGroup"; import PoweredByCal from "@components/ui/PoweredByCal"; -import type { Slot } from "@server/routers/viewer/slots"; - import type { AvailabilityPageProps } from "../../../pages/[user]/[type]"; import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]"; import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]"; @@ -123,14 +121,18 @@ const useSlots = ({ startTime: Date; endTime: Date; }) => { - const { data, isLoading } = trpc.useQuery([ - "viewer.slots.getSchedule", - { - eventTypeId, - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - }, - ]); + const { data, isLoading } = trpc.useQuery( + [ + "viewer.public.slots.getSchedule", + { + eventTypeId, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + }, + ], + /** Prevents fetching past dates */ + { enabled: dayjs(startTime).isAfter(dayjs().subtract(1, "day")) } + ); return { slots: data?.slots || {}, isLoading }; }; @@ -165,18 +167,10 @@ const SlotPicker = ({ const { slots, isLoading } = useSlots({ eventTypeId: eventType.id, - startTime: startDate, + startTime: dayjs(startDate).startOf("day").toDate(), endTime: dayjs(startDate).endOf("month").toDate(), }); - const [times, setTimes] = useState([]); - - useEffect(() => { - if (selectedDate && slots[yyyymmdd(selectedDate)]) { - setTimes(slots[yyyymmdd(selectedDate)]); - } - }, [selectedDate, slots]); - return ( <> slots[k].length > 0)} + locale={isLocaleReady ? i18n.language : "en"} selected={selectedDate} onChange={setSelectedDate} - onMonthChange={setStartDate} + onMonthChange={(startDate) => { + // set the minimum day to today in the current month, not the beginning of the month + setStartDate( + dayjs(startDate).isBefore(dayjs().subtract(1, "day")) + ? dayjs(new Date()).startOf("day").toDate() + : startDate + ); + }} weekStart={weekStart} + // DayComponent={(props) => } />
{timezoneDropdown}
{selectedDate && ( { const [selectedDate, _setSelectedDate] = useState(); useEffect(() => { + /** TODO: router.query.date is comming as `null` even when set like this: + * `/user/type?date=2022-06-22-0600` + */ const dateString = asStringOrNull(router.query.date); if (dateString) { const offsetString = dateString.substr(11, 14); // hhmm @@ -275,6 +280,7 @@ const useDateSelected = ({ timeZone }: { timeZone?: string }) => { (offsetMinute !== "" ? parseInt(offsetMinute) : 0)); const date = dayjs(dateString.substr(0, 10)).utcOffset(utcOffsetInMinutes, true); + console.log("date.isValid()", date.isValid()); if (date.isValid()) { setSelectedDate(date.toDate()); } @@ -674,4 +680,29 @@ const AvailabilityPage = ({ profile, eventType }: Props) => { ); }; +const DayContainer = (props: React.ComponentProps & { eventTypeId: number }) => { + const { eventTypeId, ...rest } = props; + /** : + * Fetch each individual day here. All these are batched with tRPC anyways. + **/ + const { slots } = useSlots({ + eventTypeId, + startTime: dayjs(props.date).startOf("day").toDate(), + endTime: dayjs(props.date).endOf("day").toDate(), + }); + const includedDates = Object.keys(slots).filter((k) => slots[k].length > 0); + const disabled = includedDates.length > 0 ? !includedDates.includes(yyyymmdd(props.date)) : props.disabled; + return ; +}; + +const AvailableTimesContainer = (props: React.ComponentProps) => { + const { date, eventTypeId } = props; + const { slots } = useSlots({ + eventTypeId, + startTime: dayjs(date).startOf("day").toDate(), + endTime: dayjs(date).endOf("day").toDate(), + }); + return ; +}; + export default AvailabilityPage; diff --git a/apps/web/components/security/DisableUserImpersonation.tsx b/apps/web/components/security/DisableUserImpersonation.tsx index 66c978398d..c17d114ce7 100644 --- a/apps/web/components/security/DisableUserImpersonation.tsx +++ b/apps/web/components/security/DisableUserImpersonation.tsx @@ -17,7 +17,7 @@ const DisableUserImpersonation = ({ disableImpersonation }: { disableImpersonati await utils.invalidateQueries(["viewer.me"]); }, async onSettled() { - await utils.invalidateQueries(["viewer.i18n"]); + await utils.invalidateQueries(["viewer.public.i18n"]); }, }); diff --git a/apps/web/lib/QueryCell.tsx b/apps/web/lib/QueryCell.tsx index b8f6edd5d1..9918d2b3c3 100644 --- a/apps/web/lib/QueryCell.tsx +++ b/apps/web/lib/QueryCell.tsx @@ -101,7 +101,13 @@ type TError = TRPCClientErrorLike; const withQuery = ( pathAndInput: [path: TPath, ...args: inferHandlerInput], - params?: UseTRPCQueryOptions + params?: UseTRPCQueryOptions< + TPath, + TQueryValues[TPath]["input"], + TQueryValues[TPath]["output"], + TQueryValues[TPath]["output"], + TError + > ) => { return function WithQuery( opts: Omit< diff --git a/apps/web/lib/app-providers.tsx b/apps/web/lib/app-providers.tsx index da7845d852..ee8ccc9bbb 100644 --- a/apps/web/lib/app-providers.tsx +++ b/apps/web/lib/app-providers.tsx @@ -26,7 +26,7 @@ type AppPropsWithChildren = AppProps & { }; const CustomI18nextProvider = (props: AppPropsWithChildren) => { - const { i18n, locale } = trpc.useQuery(["viewer.i18n"]).data ?? { + const { i18n, locale } = trpc.useQuery(["viewer.public.i18n"]).data ?? { locale: "en", }; @@ -42,7 +42,7 @@ const CustomI18nextProvider = (props: AppPropsWithChildren) => { }; const AppProviders = (props: AppPropsWithChildren) => { - const session = trpc.useQuery(["viewer.session"]).data; + const session = trpc.useQuery(["viewer.public.session"]).data; // No need to have intercom on public pages - Good for Page Performance const isPublicPage = usePublicPage(); const RemainingProviders = ( diff --git a/apps/web/lib/isOutOfBounds.tsx b/apps/web/lib/isOutOfBounds.tsx index fc0a51d6ea..85c65cf33e 100644 --- a/apps/web/lib/isOutOfBounds.tsx +++ b/apps/web/lib/isOutOfBounds.tsx @@ -1,5 +1,8 @@ import { EventType, PeriodType } from "@prisma/client"; import dayjs from "dayjs"; +import dayjsBusinessTime from "dayjs-business-days2"; + +dayjs.extend(dayjsBusinessTime); function isOutOfBounds( time: dayjs.ConfigType, diff --git a/apps/web/package.json b/apps/web/package.json index d63bed4282..4045e7b451 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -58,10 +58,10 @@ "@radix-ui/react-tooltip": "^0.1.0", "@stripe/react-stripe-js": "^1.8.0", "@stripe/stripe-js": "^1.29.0", - "@trpc/client": "^9.23.4", - "@trpc/next": "^9.23.4", - "@trpc/react": "^9.23.4", - "@trpc/server": "^9.23.4", + "@trpc/client": "^9.25.2", + "@trpc/next": "^9.25.2", + "@trpc/react": "^9.25.2", + "@trpc/server": "^9.25.2", "@vercel/edge-functions-ui": "^0.2.1", "@wojtekmaj/react-daterange-picker": "^3.3.1", "accept-language-parser": "^1.5.0", diff --git a/apps/web/pages/[user]/[type].tsx b/apps/web/pages/[user]/[type].tsx index dbfcc154d9..fc0cb92023 100644 --- a/apps/web/pages/[user]/[type].tsx +++ b/apps/web/pages/[user]/[type].tsx @@ -1,4 +1,5 @@ import { UserPlan } from "@prisma/client"; +import dayjs from "dayjs"; import { GetStaticPropsContext } from "next"; import { JSONObject } from "superjson/dist/types"; import { z } from "zod"; @@ -54,7 +55,10 @@ export default function Type(props: AvailabilityPageProps) { ); } -async function getUserPageProps({ username, slug }: { username: string; slug: string }) { +async function getUserPageProps(context: GetStaticPropsContext) { + const { type: slug, user: username } = paramsSchema.parse(context.params); + const { ssgInit } = await import("@server/lib/ssg"); + const ssg = await ssgInit(context); const user = await prisma.user.findUnique({ where: { username, @@ -150,6 +154,13 @@ async function getUserPageProps({ username, slug }: { username: string; slug: st const profile = eventType.users[0] || user; + const startTime = new Date(); + await ssg.fetchQuery("viewer.public.slots.getSchedule", { + eventTypeId: eventType.id, + startTime: dayjs(startTime).startOf("day").toISOString(), + endTime: dayjs(startTime).endOf("day").toISOString(), + }); + return { props: { eventType: eventTypeObject, @@ -168,18 +179,18 @@ async function getUserPageProps({ username, slug }: { username: string; slug: st }, away: user?.away, isDynamic: false, + trpcState: ssg.dehydrate(), }, revalidate: 10, // seconds }; } -async function getDynamicGroupPageProps({ - usernameList, - length, -}: { - usernameList: string[]; - length: number; -}) { +async function getDynamicGroupPageProps(context: GetStaticPropsContext) { + const { ssgInit } = await import("@server/lib/ssg"); + const ssg = await ssgInit(context); + const { type: typeParam, user: userParam } = paramsSchema.parse(context.params); + const usernameList = getUsernameList(userParam); + const length = parseInt(typeParam); const eventType = getDefaultEvent("" + length); const users = await prisma.user.findMany({ @@ -264,6 +275,7 @@ async function getDynamicGroupPageProps({ profile, isDynamic: true, away: false, + trpcState: ssg.dehydrate(), }, revalidate: 10, // seconds }; @@ -272,17 +284,13 @@ async function getDynamicGroupPageProps({ const paramsSchema = z.object({ type: z.string(), user: z.string() }); export const getStaticProps = async (context: GetStaticPropsContext) => { - const { type: typeParam, user: userParam } = paramsSchema.parse(context.params); - + const { user: userParam } = paramsSchema.parse(context.params); // dynamic groups are not generated at build time, but otherwise are probably cached until infinity. const isDynamicGroup = userParam.includes("+"); if (isDynamicGroup) { - return await getDynamicGroupPageProps({ - usernameList: getUsernameList(userParam), - length: parseInt(typeParam), - }); + return await getDynamicGroupPageProps(context); } else { - return await getUserPageProps({ username: userParam, slug: typeParam }); + return await getUserPageProps(context); } }; diff --git a/apps/web/pages/_app.tsx b/apps/web/pages/_app.tsx index 81d350c1c8..430fa399a7 100644 --- a/apps/web/pages/_app.tsx +++ b/apps/web/pages/_app.tsx @@ -13,7 +13,9 @@ import I18nLanguageHandler from "@components/I18nLanguageHandler"; import type { AppRouter } from "@server/routers/_app"; import { httpBatchLink } from "@trpc/client/links/httpBatchLink"; +import { httpLink } from "@trpc/client/links/httpLink"; import { loggerLink } from "@trpc/client/links/loggerLink"; +import { splitLink } from "@trpc/client/links/splitLink"; import { withTRPC } from "@trpc/next"; import type { TRPCClientErrorLike } from "@trpc/react"; import { Maybe } from "@trpc/server"; @@ -56,6 +58,13 @@ function MyApp(props: AppProps) { export default withTRPC({ config() { + const url = + typeof window !== "undefined" + ? "/api/trpc" + : process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}/api/trpc` + : `http://${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/trpc`; + /** * If you want to use SSR, you need to use the server's full URL * @link https://trpc.io/docs/ssr @@ -70,8 +79,17 @@ export default withTRPC({ enabled: (opts) => !!process.env.NEXT_PUBLIC_DEBUG || (opts.direction === "down" && opts.result instanceof Error), }), - httpBatchLink({ - url: `/api/trpc`, + splitLink({ + // check for context property `skipBatch` + condition: (op) => op.context.skipBatch === true, + // when condition is true, use normal request + true: httpLink({ url }), + // when condition is false, use batching + false: httpBatchLink({ + url, + /** @link https://github.com/trpc/trpc/issues/2008 */ + // maxBatchSize: 7 + }), }), ], /** diff --git a/apps/web/pages/api/book/request-reschedule.ts b/apps/web/pages/api/book/request-reschedule.ts index a30effc986..6c7d7fc87a 100644 --- a/apps/web/pages/api/book/request-reschedule.ts +++ b/apps/web/pages/api/book/request-reschedule.ts @@ -1,10 +1,10 @@ import { - BookingStatus, - User, - Booking, Attendee, + Booking, BookingReference, + BookingStatus, EventType, + User, WebhookTriggerEvents, } from "@prisma/client"; import dayjs from "dayjs"; @@ -13,7 +13,7 @@ import { getSession } from "next-auth/react"; import type { TFunction } from "next-i18next"; import { z, ZodError } from "zod"; -import { getCalendar } from "@calcom/core/CalendarManager"; +import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder"; import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director"; import { deleteMeeting } from "@calcom/core/videoClient"; diff --git a/apps/web/pages/api/cancel.ts b/apps/web/pages/api/cancel.ts index 75476a6aad..382369439c 100644 --- a/apps/web/pages/api/cancel.ts +++ b/apps/web/pages/api/cancel.ts @@ -3,8 +3,8 @@ import async from "async"; import dayjs from "dayjs"; import { NextApiRequest, NextApiResponse } from "next"; +import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter"; -import { getCalendar } from "@calcom/core/CalendarManager"; import { deleteMeeting } from "@calcom/core/videoClient"; import { sendCancelledEmails } from "@calcom/emails"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; diff --git a/apps/web/pages/api/trpc/[trpc].ts b/apps/web/pages/api/trpc/[trpc].ts index e0b9531d0e..1c8d6f7b74 100644 --- a/apps/web/pages/api/trpc/[trpc].ts +++ b/apps/web/pages/api/trpc/[trpc].ts @@ -29,7 +29,23 @@ export default trpcNext.createNextApiHandler({ /** * @link https://trpc.io/docs/caching#api-response-caching */ - // responseMeta() { - // // ... - // }, + responseMeta({ ctx, paths, type, errors }) { + // assuming we have all our public routes in `viewer.public` + const allPublic = paths && paths.every((path) => path.startsWith("viewer.public.")); + // checking that no procedures errored + const allOk = errors.length === 0; + // checking we're doing a query request + const isQuery = type === "query"; + + if (allPublic && allOk && isQuery) { + // cache request for 1 day + revalidate once every 5 seconds + const ONE_DAY_IN_SECONDS = 60 * 60 * 24; + return { + headers: { + "cache-control": `s-maxage=5, stale-while-revalidate=${ONE_DAY_IN_SECONDS}`, + }, + }; + } + return {}; + }, }); diff --git a/apps/web/pages/settings/profile.tsx b/apps/web/pages/settings/profile.tsx index 229421e441..834c370653 100644 --- a/apps/web/pages/settings/profile.tsx +++ b/apps/web/pages/settings/profile.tsx @@ -84,7 +84,7 @@ function SettingsView(props: ComponentProps & { localeProp: str document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" }); }, async onSettled() { - await utils.invalidateQueries(["viewer.i18n"]); + await utils.invalidateQueries(["viewer.public.i18n"]); }, }); @@ -481,7 +481,7 @@ function SettingsView(props: ComponentProps & { localeProp: str ); } -const WithQuery = withQuery(["viewer.i18n"]); +const WithQuery = withQuery(["viewer.public.i18n"]); export default function Settings(props: Props) { const { t } = useLocale(); diff --git a/apps/web/server/lib/ssg.ts b/apps/web/server/lib/ssg.ts index 5bf4104bed..a7abd8668d 100644 --- a/apps/web/server/lib/ssg.ts +++ b/apps/web/server/lib/ssg.ts @@ -37,7 +37,7 @@ export async function ssgInit(opts: GetStat }); // always preload i18n - await ssg.fetchQuery("viewer.i18n"); + await ssg.fetchQuery("viewer.public.i18n"); return ssg; } diff --git a/apps/web/server/lib/ssr.ts b/apps/web/server/lib/ssr.ts index 8f492b9038..c57a0a5111 100644 --- a/apps/web/server/lib/ssr.ts +++ b/apps/web/server/lib/ssr.ts @@ -21,8 +21,8 @@ export async function ssrInit(context: GetServerSidePropsContext) { ctx, }); - // always preload "viewer.i18n" - await ssr.fetchQuery("viewer.i18n"); + // always preload "viewer.public.i18n" + await ssr.fetchQuery("viewer.public.i18n"); return ssr; } diff --git a/apps/web/server/routers/viewer.tsx b/apps/web/server/routers/viewer.tsx index d48a3fb46a..75bbef1e84 100644 --- a/apps/web/server/routers/viewer.tsx +++ b/apps/web/server/routers/viewer.tsx @@ -68,7 +68,8 @@ const publicViewerRouter = createRouter() return await samlTenantProduct(prisma, email); }, - }); + }) + .merge("slots.", slotsRouter); // routes only available to authenticated users const loggedInViewerRouter = createProtectedRouter() @@ -944,12 +945,11 @@ const loggedInViewerRouter = createProtectedRouter() }); export const viewerRouter = createRouter() - .merge(publicViewerRouter) + .merge("public.", publicViewerRouter) .merge(loggedInViewerRouter) .merge("bookings.", bookingsRouter) .merge("eventTypes.", eventTypesRouter) .merge("availability.", availabilityRouter) .merge("teams.", viewerTeamsRouter) .merge("webhook.", webhookRouter) - .merge("apiKeys.", apiKeysRouter) - .merge("slots.", slotsRouter); + .merge("apiKeys.", apiKeysRouter); diff --git a/apps/web/server/routers/viewer/slots.tsx b/apps/web/server/routers/viewer/slots.tsx index 421124bbb7..a9d4a67f17 100644 --- a/apps/web/server/routers/viewer/slots.tsx +++ b/apps/web/server/routers/viewer/slots.tsx @@ -9,6 +9,7 @@ import { availabilityUserSelect } from "@calcom/prisma"; import { stringToDayjs } from "@calcom/prisma/zod-utils"; import { TimeRange, WorkingHours } from "@calcom/types/schedule"; +import isOutOfBounds from "@lib/isOutOfBounds"; import getSlots from "@lib/slots"; import { createRouter } from "@server/createRouter"; @@ -137,6 +138,11 @@ export const slotsRouter = createRouter().query("getSchedule", { beforeEventBuffer: true, afterEventBuffer: true, schedulingType: true, + periodType: true, + periodStartDate: true, + periodEndDate: true, + periodCountCalendarDays: true, + periodDays: true, schedule: { select: { availability: true, @@ -202,6 +208,14 @@ export const slotsRouter = createRouter().query("getSchedule", { afterBufferTime: eventType.afterEventBuffer, currentSeats, }; + const isWithinBounds = (_time: Parameters[0]) => + !isOutOfBounds(_time, { + periodType: eventType.periodType, + periodStartDate: eventType.periodStartDate, + periodEndDate: eventType.periodEndDate, + periodCountCalendarDays: eventType.periodCountCalendarDays, + periodDays: eventType.periodDays, + }); let time = dayjs(startTime); do { @@ -215,18 +229,17 @@ export const slotsRouter = createRouter().query("getSchedule", { }); // if ROUND_ROBIN - slots stay available on some() - if normal / COLLECTIVE - slots only stay available on every() - const filteredTimes = + const filterStrategy = !eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE - ? times.filter((time) => - userSchedules.every((schedule) => - checkForAvailability({ time, ...schedule, ...availabilityCheckProps }) - ) - ) - : times.filter((time) => - userSchedules.some((schedule) => - checkForAvailability({ time, ...schedule, ...availabilityCheckProps }) - ) - ); + ? ("every" as const) + : ("some" as const); + const filteredTimes = times + .filter(isWithinBounds) + .filter((time) => + userSchedules[filterStrategy]((schedule) => + checkForAvailability({ time, ...schedule, ...availabilityCheckProps }) + ) + ); slots[yyyymmdd(time.toDate())] = filteredTimes.map((time) => ({ time: time.toISOString(), diff --git a/packages/core/CalendarManager.ts b/packages/core/CalendarManager.ts index 5400c2cc13..86e996afb0 100644 --- a/packages/core/CalendarManager.ts +++ b/packages/core/CalendarManager.ts @@ -1,21 +1,18 @@ import { Credential, SelectedCalendar } from "@prisma/client"; +import { createHash } from "crypto"; import _ from "lodash"; +import cache from "memory-cache"; import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import getApps from "@calcom/app-store/utils"; import { getUid } from "@calcom/lib/CalEventParser"; import { getErrorFromUnknown } from "@calcom/lib/errors"; import logger from "@calcom/lib/logger"; -import notEmpty from "@calcom/lib/notEmpty"; import type { CalendarEvent, EventBusyDate, NewCalendarEventType } from "@calcom/types/Calendar"; -import type { Event } from "@calcom/types/Event"; import type { EventResult } from "@calcom/types/EventManager"; const log = logger.getChildLogger({ prefix: ["CalendarManager"] }); -/** TODO: Remove once all references are updated to app-store */ -export { getCalendar }; - export const getCalendarCredentials = (credentials: Array, userId: number) => { const calendarCredentials = getApps(credentials) .filter((app) => app.type.endsWith("_calendar")) @@ -77,20 +74,51 @@ export const getConnectedCalendars = async ( return connectedCalendars; }; +const CACHING_TIME = 30_000; // 30 seconds + +const getCachedResults = ( + withCredentials: Credential[], + dateFrom: string, + dateTo: string, + selectedCalendars: SelectedCalendar[] +) => { + const calendarCredentials = withCredentials.filter((credential) => credential.type.endsWith("_calendar")); + const calendars = calendarCredentials.map((credential) => getCalendar(credential)); + const results = calendars.map(async (c, i) => { + /** Filter out nulls */ + if (!c) return []; + /** We rely on the index so we can match credentials with calendars */ + const { id, type } = calendarCredentials[i]; + /** We just pass the calendars that matched the credential type, + * TODO: Migrate credential type or appId + */ + const passedSelectedCalendars = selectedCalendars.filter((sc) => sc.integration === type); + /** We extract external Ids so we don't cache too much */ + const selectedCalendarIds = passedSelectedCalendars.map((sc) => sc.externalId); + /** We create a unque hash key based on the input data */ + const cacheKey = createHash("md5").update(JSON.stringify({ id, selectedCalendarIds })).digest("hex"); + /** Check if we already have cached data and return */ + const cachedAvailability = cache.get(cacheKey); + if (cachedAvailability) return cachedAvailability; + /** If we don't then we actually fetch external calendars (which can be very slow) */ + const availability = await c.getAvailability(dateFrom, dateTo, passedSelectedCalendars); + /** We save the availability to a few seconds so recurrent calls are nearly instant */ + cache.put(cacheKey, availability, CACHING_TIME); + return availability; + }); + + return Promise.all(results); +}; + export const getBusyCalendarTimes = async ( withCredentials: Credential[], dateFrom: string, dateTo: string, selectedCalendars: SelectedCalendar[] ) => { - const calendars = withCredentials - .filter((credential) => credential.type.endsWith("_calendar")) - .map((credential) => getCalendar(credential)) - .filter(notEmpty); - let results: EventBusyDate[][] = []; try { - results = await Promise.all(calendars.map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars))); + results = await getCachedResults(withCredentials, dateFrom, dateTo, selectedCalendars); } catch (error) { log.warn(error); } diff --git a/packages/ui/booker/DatePicker.tsx b/packages/ui/booker/DatePicker.tsx index 28134cbbeb..75ed1636ec 100644 --- a/packages/ui/booker/DatePicker.tsx +++ b/packages/ui/booker/DatePicker.tsx @@ -34,19 +34,15 @@ export type DatePickerProps = { isLoading?: boolean; }; -const Day = ({ +export const Day = ({ date, active, ...props }: JSX.IntrinsicElements["button"] & { active: boolean; date: Date }) => { return (