[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:
parent
3bae13eea8
commit
cf186e58bd
3 changed files with 145 additions and 28 deletions
|
@ -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 />;
|
||||
|
|
48
apps/web/lib/doWorkAsync.ts
Normal file
48
apps/web/lib/doWorkAsync.ts
Normal 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 });
|
||||
});
|
||||
};
|
|
@ -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,
|
||||
"[)"
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue