Optimize AvailabilityPicker performance

This commit is contained in:
Miroslav Vasilev 2024-01-10 10:19:54 +02:00
parent a2bb7fab1c
commit 988431d004
4 changed files with 196 additions and 142 deletions

View file

@ -13,7 +13,10 @@
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/material": "^5.15.3",
"@mui/system": "^5.15.3",
"@mui/x-date-pickers": "^6.18.7",
"classname": "^0.0.0",
"classnames": "^2.5.1",
"dayjs": "^1.11.10",
"react": "^18.2.0",
"react-dom": "^18.2.0",

View file

@ -1,12 +1,13 @@
import { Box, Card, Divider, Stack, Theme, Typography, useTheme } from "@mui/material";
import { Box, Card, Divider, Stack, Typography } from "@mui/material";
import dayjs, { Dayjs } from "dayjs";
import { MouseEvent, ReactNode, useEffect, useState } from "react";
import { MouseEvent, useEffect, useState } from "react";
import * as utc from 'dayjs/plugin/utc';
import * as timezone from 'dayjs/plugin/timezone';
import * as localizedFormat from 'dayjs/plugin/localizedFormat';
import utils from "../utils";
import { EventTypes } from "../types/Event";
import "./css/AvailabilityPicker.css";
import classNames from 'classnames';
// import { alpha } from '@material-ui/core/styles/colorManipulator';
dayjs.extend(utc)
@ -26,21 +27,125 @@ type AvailabilityDay = {
type GhostPreviewProps = {
top: number,
left: number,
width: String,
height: String
width: number,
height: number
}
const HALFHOUR_DISPLAY_HEIGHT: number = 15;
const DAY_DISPLAY_WIDTH: String = "150px";
const Hour = (props: {
dateTime: Dayjs,
isFullHourSelected: boolean,
isHalfHourSelected: boolean,
onMouseEnterHalfhour: (e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: Dayjs) => void,
onMouseClickOnHalfhour: (time: Dayjs) => void
}) => {
let isEvenHour = props.dateTime.hour() % 2 == 0;
export default function AvailabilityPicker(props: {
return (
<Box
className={classNames({ "hour-light": isEvenHour, "hour-dark": !isEvenHour })}
>
<Box
className={classNames("full-hour", { "selected-availability": props.isFullHourSelected })}
height={HALFHOUR_DISPLAY_HEIGHT}
onMouseEnter={(e) => props.onMouseEnterHalfhour(e, props.dateTime)}
onClick={(_) => props.onMouseClickOnHalfhour(props.dateTime)}
>
<Typography className={"noselect time-text"}>
{ utils.formatTimeFromHourOfDay(props.dateTime.hour(), 0) }
</Typography>
</Box>
<Box
className={classNames("half-hour", { "selected-availability": props.isHalfHourSelected })}
height={HALFHOUR_DISPLAY_HEIGHT}
onMouseEnter={(e) => props.onMouseEnterHalfhour(e, props.dateTime.add(30, "minutes"))}
onClick={(_) => props.onMouseClickOnHalfhour(props.dateTime.add(30, "minutes"))}
/>
</Box>
);
}
const isSelectedAvailability = (day: AvailabilityDay, time: Dayjs): boolean => {
return day.availableTimes.some(t => t.fromTime.unix() <= time.unix() && time.unix() <= t.toTime.unix());
}
const Day = (props: {
day: AvailabilityDay,
eventType: String,
onMouseEnterHalfhour: (e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: dayjs.Dayjs) => void,
onMouseClickHalfhour: (day: AvailabilityDay, time: dayjs.Dayjs) => void
}) => {
const generateHours = (): JSX.Element[] => {
let hours: JSX.Element[] = [];
for (var i = 0; i < 24; i++) {
let time = props.day.forDate.set("hour", i);
hours.push(
<Hour
key={time.unix()}
dateTime={time}
isFullHourSelected={isSelectedAvailability(props.day, time)}
isHalfHourSelected={isSelectedAvailability(props.day, time.set("minutes", 30))}
onMouseEnterHalfhour={(e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: dayjs.Dayjs): void => {
props.onMouseEnterHalfhour(e, time);
}}
onMouseClickOnHalfhour={(time: dayjs.Dayjs): void => {
props.onMouseClickHalfhour(props.day, time);
}}
/>
);
}
return hours;
}
return (
<Card
key={props.day.forDate.format()}
className={"day-card"}
variant="outlined"
>
<Box
sx={{ width: "100%" }}
padding={1}
>
{
(props.eventType === EventTypes.WEEK) &&
<Typography>
{ props.day.forDate.format("dddd") }
</Typography>
}
{
(props.eventType === EventTypes.DAY) &&
<Typography>
Any Day
</Typography>
}
{
(props.eventType === EventTypes.DATE_RANGE || props.eventType === EventTypes.SPECIFIC_DATE) &&
<Typography>
{ props.day.forDate.format("LL") }
</Typography>
}
</Box>
<Divider></Divider>
{generateHours()}
</Card>
);
}
const AvailabilityPicker = (props: {
fromDate: Dayjs,
toDate: Dayjs,
eventType: String,
availabilityDurationInMinutes: number
}) {
const theme: Theme = useTheme();
}) => {
const [days, setDays] = useState<AvailabilityDay[]>([]);
const [ghostPreviewProps, setGhostPreviewProps] = useState<GhostPreviewProps | null>();
@ -71,11 +176,11 @@ export default function AvailabilityPicker(props: {
}
}, [props]);
function createAvailabilitiesBasedOnUnspecifiedInitialDate(numberOfDays: number, tz: string) {
const createAvailabilitiesBasedOnUnspecifiedInitialDate = (numberOfDays: number, tz: string) => {
createAvailabilitiesBasedOnInitialDate(dayjs.tz("1970-01-05 00:00:00", tz), numberOfDays);
}
function createAvailabilitiesBasedOnInitialDate(date: Dayjs, numberOfDays: number) {
const createAvailabilitiesBasedOnInitialDate = (date: Dayjs, numberOfDays: number) => {
let availabilities: AvailabilityDay[] = [];
for (var i: number = 0; i < numberOfDays; i++) {
@ -90,7 +195,7 @@ export default function AvailabilityPicker(props: {
setDays(availabilities);
}
function displayGhostPeriod(e: React.MouseEvent<HTMLDivElement, MouseEvent>, time: Dayjs) {
const displayGhostPeriod = (e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: Dayjs) => {
let timeInMinutes = (time.hour() * 60) + time.minute();
let timeLeftInDayLessThanDuration = (timeInMinutes + props.availabilityDurationInMinutes) > 24 * 60;
@ -101,17 +206,20 @@ export default function AvailabilityPicker(props: {
let scrollTop = document.getElementById('availability-picker')?.scrollTop ?? 0;
let scrollLeft = document.getElementById('availability-picker')?.scrollLeft ?? 0;
// @ts-ignore
const element = e.target.getBoundingClientRect();
setGhostPreviewProps({
// @ts-ignore
top: e.target?.offsetTop - scrollTop,
// @ts-ignore
left: e.target?.offsetLeft - scrollLeft,
width: element.width,
height: `${(props.availabilityDurationInMinutes/60) * 2 * HALFHOUR_DISPLAY_HEIGHT}px`
height: (props.availabilityDurationInMinutes/60) * 2 * HALFHOUR_DISPLAY_HEIGHT
})
}
function createAvailability(day: AvailabilityDay, time: Dayjs) {
const createAvailability = (day: AvailabilityDay, time: Dayjs) => {
let fromTime = time;
let toTime = time.add(props.availabilityDurationInMinutes, "minutes");
@ -175,151 +283,47 @@ export default function AvailabilityPicker(props: {
setDays([...days])
}
function isSelectedAvailability(day: AvailabilityDay, time: Dayjs): boolean {
return day.availableTimes.some(t => (t.fromTime.isBefore(time) || t.fromTime.isSame(time)) && (t.toTime.isAfter(time) || t.toTime.isSame(time)));
}
function generateDay(day: AvailabilityDay) {
const HOUR_TEXT_COLOR: String = "#ddd";
let hours = [];
for (var i = 0; i < 24; i++) {
let time = day.forDate.set("hour", i).set("minute", 0).set("second", 0);
hours.push(
<Box
key={`${i}`}
className={(i % 2 == 0) ? "hour-light" : "hour-dark"}
>
<Box
className={[
"full-hour",
isSelectedAvailability(day, time) && "selected-availability"
]}
height={HALFHOUR_DISPLAY_HEIGHT}
onMouseEnter={(e: MouseEvent<HTMLDivElement, MouseEvent>) => displayGhostPeriod(e, time)}
onClick={(e) => createAvailability(day, time)}
>
<Typography
className={"noselect"}
textAlign={"left"}
fontSize={"0.65em"}
color={HOUR_TEXT_COLOR}
>
{ utils.formatTimeFromHourOfDay(i, 0) }
</Typography>
</Box>
<Box
className={[
"half-hour",
isSelectedAvailability(day, time) && "selected-availability"
]}
height={HALFHOUR_DISPLAY_HEIGHT}
onMouseEnter={(e: MouseEvent<HTMLDivElement, MouseEvent>) => displayGhostPeriod(e, time.add(30, "minutes"))}
onClick={(e) => createAvailability(day, time.add(30, "minutes"))}
>
</Box>
</Box>
);
}
return (
<Stack
key={day.forDate.format()}
direction="column"
sx={{
minWidth: DAY_DISPLAY_WIDTH,
width: DAY_DISPLAY_WIDTH
}}
overflow={"visible"}
>
<Card
sx={{
width: "100%",
height: "fit-content",
overflow: "visible"
}}
variant="outlined"
>
<Box
sx={{ width: "100%" }}
padding={1}
>
{
(props.eventType === EventTypes.WEEK) &&
<Typography>
{ day.forDate.format("dddd") }
</Typography>
}
{
(props.eventType === EventTypes.DAY) &&
<Typography>
Any Day
</Typography>
}
{
(props.eventType === EventTypes.DATE_RANGE || props.eventType === EventTypes.SPECIFIC_DATE) &&
<Typography>
{ day.forDate.format("LL") }
</Typography>
}
</Box>
<Divider></Divider>
{hours}
</Card>
</Stack>
);
}
return (
<Box
sx={{
position: "relative",
width: "100%",
height: "auto",
overflow: "hidden"
}}
className={"availability-parent-box"}
>
<Stack
id="availability-picker"
direction="row"
spacing={1}
justifyContent={"safe center"}
sx={{
width: "100%",
height: "500px",
overflowY: "scroll",
overflowX: "scroll"
}}
onScroll={(e) => setGhostPreviewProps(null)}
className={"availability-parent-stack"}
onScroll={(_) => setGhostPreviewProps(null)}
>
{
days.map(a => generateDay(a))
days.map(day =>
<Day
key={day.forDate.unix()}
day={day}
eventType={props.eventType}
onMouseEnterHalfhour={(e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: dayjs.Dayjs) => {
displayGhostPeriod(e, time);
}}
onMouseClickHalfhour={(day: AvailabilityDay, time: dayjs.Dayjs) => {
createAvailability(day, time);
}}
/>
)
}
</Stack>
{
(ghostPreviewProps !== null) &&
<Box
sx={{
position: "absolute",
top: ghostPreviewProps?.top,
left: ghostPreviewProps?.left,
width: ghostPreviewProps?.width,
height: ghostPreviewProps?.height,
bgcolor: "rgba(0, 255, 0, 0.1)",
border: 1,
borderColor: "#272",
borderRadius: 1,
m: 0,
p: 0,
pointerEvents: "none"
}}
className={"ghost-box"}
top={ghostPreviewProps?.top}
left={ghostPreviewProps?.left}
width={ghostPreviewProps?.width}
height={ghostPreviewProps?.height}
>
</Box>
}
</Box>
);
}
}
export default AvailabilityPicker;

View file

@ -43,4 +43,41 @@ div.full-hour:hover, div.half-hour:hover {
div.full-hour:active, div.half-hour:active {
background-color: var(--active-color);
}
div.ghost-box {
position: absolute;
background-color: rgba(0, 255, 0, 0.1);
border: solid 1px;
border-color: #227722;
border-radius: 1;
margin: 0;
padding: 0;
pointer-events: none;
}
p.time-text {
text-align: left;
font-size: 0.65em;
color: #ddd;
}
div.day-card {
width: 120px;
min-width: 120px;
height: fit-content;
overflow: visible;
}
div.availability-parent-box {
position: relative;
width: 100%;
height: auto;
overflow: hidden;
}
div.availability-parent-stack {
width: 100%;
height: 500px;
overflow: scroll;
}

View file

@ -616,7 +616,7 @@
"@mui/system@^5.15.3":
version "5.15.3"
resolved "https://registry.npmjs.org/@mui/system/-/system-5.15.3.tgz"
resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.15.3.tgz#062d0d6b5259c3dc0e1d4026b85ffcc3acf8637b"
integrity sha512-ewVU4eRgo4VfNMGpO61cKlfWmH7l9s6rA8EknRzuMX3DbSLfmtW2WJJg6qPwragvpPIir0Pp/AdWVSDhyNy5Tw==
dependencies:
"@babel/runtime" "^7.23.6"
@ -1069,6 +1069,16 @@ chalk@^4.0.0:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
classname@^0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/classname/-/classname-0.0.0.tgz#43d171b484e354c7a293a5b78004df6852db20d6"
integrity sha512-kkhsspEJdUW+VhuvNzb2sQf0KbafDPfd36dB1qf03Uu42dWZwMQzaQuyNkaRr5ir0ZiAN0+TlH/EOOfwb/aaXg==
classnames@^2.5.1:
version "2.5.1"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b"
integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==
clsx@^2.0.0:
version "2.1.0"
resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz"