[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 dayjsBusinessTime from "dayjs-business-time";
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import timezone from "dayjs/plugin/timezone";
|
||||||
import utc from "dayjs/plugin/utc";
|
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 classNames from "@lib/classNames";
|
||||||
import { timeZone } from "@lib/clock";
|
import { timeZone } from "@lib/clock";
|
||||||
import { weekdayNames } from "@lib/core/i18n/weekday";
|
import { weekdayNames } from "@lib/core/i18n/weekday";
|
||||||
|
import { doWorkAsync } from "@lib/doWorkAsync";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
import getSlots from "@lib/slots";
|
import getSlots from "@lib/slots";
|
||||||
import { WorkingHours } from "@lib/types/schedule";
|
import { WorkingHours } from "@lib/types/schedule";
|
||||||
|
@ -87,7 +89,13 @@ function DatePicker({
|
||||||
const [month, setMonth] = useState<string>("");
|
const [month, setMonth] = useState<string>("");
|
||||||
const [year, setYear] = useState<string>("");
|
const [year, setYear] = useState<string>("");
|
||||||
const [isFirstMonth, setIsFirstMonth] = useState<boolean>(false);
|
const [isFirstMonth, setIsFirstMonth] = useState<boolean>(false);
|
||||||
|
const [daysFromState, setDays] = useState<
|
||||||
|
| {
|
||||||
|
disabled: Boolean;
|
||||||
|
date: number;
|
||||||
|
}[]
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!browsingDate || (date && browsingDate.utcOffset() !== date?.utcOffset())) {
|
if (!browsingDate || (date && browsingDate.utcOffset() !== date?.utcOffset())) {
|
||||||
setBrowsingDate(date || dayjs().tz(timeZone()));
|
setBrowsingDate(date || dayjs().tz(timeZone()));
|
||||||
|
@ -99,23 +107,24 @@ function DatePicker({
|
||||||
setMonth(browsingDate.toDate().toLocaleString(i18n.language, { month: "long" }));
|
setMonth(browsingDate.toDate().toLocaleString(i18n.language, { month: "long" }));
|
||||||
setYear(browsingDate.format("YYYY"));
|
setYear(browsingDate.format("YYYY"));
|
||||||
setIsFirstMonth(browsingDate.startOf("month").isBefore(dayjs()));
|
setIsFirstMonth(browsingDate.startOf("month").isBefore(dayjs()));
|
||||||
|
setDays(null);
|
||||||
}
|
}
|
||||||
}, [browsingDate, i18n.language]);
|
}, [browsingDate, i18n.language]);
|
||||||
|
|
||||||
const days = useMemo(() => {
|
const isDisabled = (
|
||||||
if (!browsingDate) {
|
day: number,
|
||||||
return [];
|
{
|
||||||
|
browsingDate,
|
||||||
|
periodType,
|
||||||
|
periodStartDate,
|
||||||
|
periodEndDate,
|
||||||
|
periodCountCalendarDays,
|
||||||
|
periodDays,
|
||||||
|
eventLength,
|
||||||
|
minimumBookingNotice,
|
||||||
|
workingHours,
|
||||||
}
|
}
|
||||||
// Create placeholder elements for empty days in first week
|
) => {
|
||||||
let weekdayOfFirst = browsingDate.date(1).day();
|
|
||||||
if (weekStart === "Monday") {
|
|
||||||
weekdayOfFirst -= 1;
|
|
||||||
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
const days = Array(weekdayOfFirst).fill(null);
|
|
||||||
|
|
||||||
const isDisabled = (day: number) => {
|
|
||||||
const date = browsingDate.startOf("day").date(day);
|
const date = browsingDate.startOf("day").date(day);
|
||||||
return (
|
return (
|
||||||
isOutOfBounds(date, {
|
isOutOfBounds(date, {
|
||||||
|
@ -134,14 +143,72 @@ function DatePicker({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const daysInMonth = browsingDate.daysInMonth();
|
const isDisabledRef = useRef(
|
||||||
for (let i = 1; i <= daysInMonth; i++) {
|
memoize(isDisabled, (day, { browsingDate }) => {
|
||||||
days.push({ disabled: isDisabled(i), date: i });
|
// 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") {
|
||||||
|
weekdayOfFirst -= 1;
|
||||||
|
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Array(weekdayOfFirst).fill(null);
|
||||||
|
|
||||||
|
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: 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;
|
return days;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [browsingDate]);
|
})();
|
||||||
|
|
||||||
if (!browsingDate) {
|
if (!browsingDate) {
|
||||||
return <Loader />;
|
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) => {
|
const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours }: GetSlots) => {
|
||||||
// current date in invitee tz
|
// current date in invitee tz
|
||||||
const startDate = dayjs().add(minimumBookingNotice, "minute");
|
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
|
// checks if the start date is in the past
|
||||||
if (inviteeDate.isBefore(startDate, "day")) {
|
if (inviteeDate.isBefore(startDate, "day")) {
|
||||||
return [];
|
return [];
|
||||||
|
@ -36,14 +38,14 @@ const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours }
|
||||||
{ utcOffset: -inviteeDate.utcOffset() },
|
{ utcOffset: -inviteeDate.utcOffset() },
|
||||||
workingHours.map((schedule) => ({
|
workingHours.map((schedule) => ({
|
||||||
days: schedule.days,
|
days: schedule.days,
|
||||||
startTime: dayjs.utc().startOf("day").add(schedule.startTime, "minute"),
|
startTime: startOfDay.add(schedule.startTime, "minute"),
|
||||||
endTime: dayjs.utc().startOf("day").add(schedule.endTime, "minute"),
|
endTime: startOfDay.add(schedule.endTime, "minute"),
|
||||||
}))
|
}))
|
||||||
).filter((hours) => hours.days.includes(inviteeDate.day()));
|
).filter((hours) => hours.days.includes(inviteeDate.day()));
|
||||||
|
|
||||||
const slots: Dayjs[] = [];
|
const slots: Dayjs[] = [];
|
||||||
for (let minutes = getMinuteOffset(inviteeDate, frequency); minutes < 1440; minutes += frequency) {
|
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
|
// check if slot happened already
|
||||||
if (slot.isBefore(startDate)) {
|
if (slot.isBefore(startDate)) {
|
||||||
continue;
|
continue;
|
||||||
|
@ -52,8 +54,8 @@ const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours }
|
||||||
if (
|
if (
|
||||||
localWorkingHours.some((hours) =>
|
localWorkingHours.some((hours) =>
|
||||||
slot.isBetween(
|
slot.isBetween(
|
||||||
inviteeDate.startOf("day").add(hours.startTime, "minute"),
|
startOfInviteeDay.add(hours.startTime, "minute"),
|
||||||
inviteeDate.startOf("day").add(hours.endTime, "minute"),
|
startOfInviteeDay.add(hours.endTime, "minute"),
|
||||||
null,
|
null,
|
||||||
"[)"
|
"[)"
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in a new issue