[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>
This commit is contained in:
hariombalhara 2022-02-27 05:49:50 +05:30 committed by GitHub
parent 3bae13eea8
commit cf186e58bd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 145 additions and 28 deletions

View file

@ -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<string>("");
const [year, setYear] = useState<string>("");
const [isFirstMonth, setIsFirstMonth] = useState<boolean>(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 <Loader />;

View file

@ -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 });
});
};

View file

@ -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,
"[)"
)