From cf186e58bd77b973926fbaa6ebb9db616dab3f81 Mon Sep 17 00:00:00 2001 From: hariombalhara Date: Sun, 27 Feb 2022 05:49:50 +0530 Subject: [PATCH] [Perf Improvement] Event Booking Date Picker (#1980) * Memoize and remove repeat calls of functions * Better fn names * Remove unnecessary code change * Process dates asyncly * Avoid waste work * Add comments Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- apps/web/components/booking/DatePicker.tsx | 113 ++++++++++++++++----- apps/web/lib/doWorkAsync.ts | 48 +++++++++ apps/web/lib/slots.ts | 12 ++- 3 files changed, 145 insertions(+), 28 deletions(-) create mode 100644 apps/web/lib/doWorkAsync.ts diff --git a/apps/web/components/booking/DatePicker.tsx b/apps/web/components/booking/DatePicker.tsx index 24700537..f6549b1c 100644 --- a/apps/web/components/booking/DatePicker.tsx +++ b/apps/web/components/booking/DatePicker.tsx @@ -4,11 +4,13 @@ import dayjs, { Dayjs } from "dayjs"; import dayjsBusinessTime from "dayjs-business-time"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; -import { useEffect, useMemo, useState } from "react"; +import { memoize } from "lodash"; +import { useEffect, useMemo, useRef, useState } from "react"; import classNames from "@lib/classNames"; import { timeZone } from "@lib/clock"; import { weekdayNames } from "@lib/core/i18n/weekday"; +import { doWorkAsync } from "@lib/doWorkAsync"; import { useLocale } from "@lib/hooks/useLocale"; import getSlots from "@lib/slots"; import { WorkingHours } from "@lib/types/schedule"; @@ -87,7 +89,13 @@ function DatePicker({ const [month, setMonth] = useState(""); const [year, setYear] = useState(""); const [isFirstMonth, setIsFirstMonth] = useState(false); - + const [daysFromState, setDays] = useState< + | { + disabled: Boolean; + date: number; + }[] + | null + >(null); useEffect(() => { if (!browsingDate || (date && browsingDate.utcOffset() !== date?.utcOffset())) { setBrowsingDate(date || dayjs().tz(timeZone())); @@ -99,13 +107,56 @@ function DatePicker({ setMonth(browsingDate.toDate().toLocaleString(i18n.language, { month: "long" })); setYear(browsingDate.format("YYYY")); setIsFirstMonth(browsingDate.startOf("month").isBefore(dayjs())); + setDays(null); } }, [browsingDate, i18n.language]); - const days = useMemo(() => { + const isDisabled = ( + day: number, + { + browsingDate, + periodType, + periodStartDate, + periodEndDate, + periodCountCalendarDays, + periodDays, + eventLength, + minimumBookingNotice, + workingHours, + } + ) => { + const date = browsingDate.startOf("day").date(day); + return ( + isOutOfBounds(date, { + periodType, + periodStartDate, + periodEndDate, + periodCountCalendarDays, + periodDays, + }) || + !getSlots({ + inviteeDate: date, + frequency: eventLength, + minimumBookingNotice, + workingHours, + }).length + ); + }; + + const isDisabledRef = useRef( + memoize(isDisabled, (day, { browsingDate }) => { + // Make a composite cache key + return day + "_" + browsingDate.toString(); + }) + ); + + const days = (() => { if (!browsingDate) { return []; } + if (daysFromState) { + return daysFromState; + } // Create placeholder elements for empty days in first week let weekdayOfFirst = browsingDate.date(1).day(); if (weekStart === "Monday") { @@ -115,33 +166,49 @@ function DatePicker({ const days = Array(weekdayOfFirst).fill(null); - const isDisabled = (day: number) => { - const date = browsingDate.startOf("day").date(day); - return ( - isOutOfBounds(date, { - periodType, - periodStartDate, - periodEndDate, - periodCountCalendarDays, - periodDays, - }) || - !getSlots({ - inviteeDate: date, - frequency: eventLength, - minimumBookingNotice, - workingHours, - }).length - ); - }; + const isDisabledMemoized = isDisabledRef.current; const daysInMonth = browsingDate.daysInMonth(); + const daysInitialOffset = days.length; + + // Build UI with All dates disabled for (let i = 1; i <= daysInMonth; i++) { - days.push({ disabled: isDisabled(i), date: i }); + days.push({ + disabled: true, + date: i, + }); } + // Update dates with their availability + doWorkAsync({ + batch: 5, + name: "DatePicker", + length: daysInMonth, + callback: (i: number, isLast) => { + let day = i + 1; + days[daysInitialOffset + i] = { + disabled: isDisabledMemoized(day, { + browsingDate, + periodType, + periodStartDate, + periodEndDate, + periodCountCalendarDays, + periodDays, + eventLength, + minimumBookingNotice, + workingHours, + }), + date: day, + }; + }, + done: () => { + setDays(days); + }, + }); + return days; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [browsingDate]); + })(); if (!browsingDate) { return ; diff --git a/apps/web/lib/doWorkAsync.ts b/apps/web/lib/doWorkAsync.ts new file mode 100644 index 00000000..c36d40b6 --- /dev/null +++ b/apps/web/lib/doWorkAsync.ts @@ -0,0 +1,48 @@ +const data = {}; +/** + * Starts an iteration from `0` to `length - 1` with batch size `batch` + * + * `callback` is called per iteration + * + * `done` is called when all iterations are done + * + * `name` is a unique identifier for the work. It allows the work that is not required to be dropped. + */ +export const doWorkAsync = ({ + length, + name, + callback, + done, + batch, + offsetStart, + __pending, +}: { + name: string; + length: number; + callback: Function; + done: Function; + batch: number; + offsetStart?: number; + __pending?: boolean; +}) => { + offsetStart = offsetStart || 0; + + const stepLength = batch; + const lastIndex = length - 1; + const offsetEndExclusive = offsetStart + stepLength; + if (!__pending && data[name]) { + cancelAnimationFrame(data[name]); + } + if (offsetStart >= length) { + done(); + return; + } + + for (let i = offsetStart; i < offsetEndExclusive && i < length; i++) { + callback(i, offsetEndExclusive > lastIndex); + } + + data[name] = requestAnimationFrame(() => { + doWorkAsync({ length, callback, name, batch, done, offsetStart: offsetEndExclusive, __pending: true }); + }); +}; diff --git a/apps/web/lib/slots.ts b/apps/web/lib/slots.ts index 2ec824bc..250da359 100644 --- a/apps/web/lib/slots.ts +++ b/apps/web/lib/slots.ts @@ -27,6 +27,8 @@ const getMinuteOffset = (date: Dayjs, frequency: number) => { const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours }: GetSlots) => { // current date in invitee tz const startDate = dayjs().add(minimumBookingNotice, "minute"); + const startOfDay = dayjs.utc().startOf("day"); + const startOfInviteeDay = inviteeDate.startOf("day"); // checks if the start date is in the past if (inviteeDate.isBefore(startDate, "day")) { return []; @@ -36,14 +38,14 @@ const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours } { utcOffset: -inviteeDate.utcOffset() }, workingHours.map((schedule) => ({ days: schedule.days, - startTime: dayjs.utc().startOf("day").add(schedule.startTime, "minute"), - endTime: dayjs.utc().startOf("day").add(schedule.endTime, "minute"), + startTime: startOfDay.add(schedule.startTime, "minute"), + endTime: startOfDay.add(schedule.endTime, "minute"), })) ).filter((hours) => hours.days.includes(inviteeDate.day())); const slots: Dayjs[] = []; for (let minutes = getMinuteOffset(inviteeDate, frequency); minutes < 1440; minutes += frequency) { - const slot = dayjs(inviteeDate).startOf("day").add(minutes, "minute"); + const slot = startOfInviteeDay.add(minutes, "minute"); // check if slot happened already if (slot.isBefore(startDate)) { continue; @@ -52,8 +54,8 @@ const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours } if ( localWorkingHours.some((hours) => slot.isBetween( - inviteeDate.startOf("day").add(hours.startTime, "minute"), - inviteeDate.startOf("day").add(hours.endTime, "minute"), + startOfInviteeDay.add(hours.startTime, "minute"), + startOfInviteeDay.add(hours.endTime, "minute"), null, "[)" )