From fe50c21f84745fdf0181c1ee02455dc8fd6b3b78 Mon Sep 17 00:00:00 2001 From: mvvasilev Date: Fri, 12 Jan 2024 11:53:39 +0200 Subject: [PATCH] Optimize availability picker, use new approach for displaying available times --- .../src/components/AvailabilityPicker.tsx | 137 ++++++++++-------- .../src/components/AvailabilityPickerDay.tsx | 21 +-- .../src/components/AvailabilityPickerHour.tsx | 16 +- .../src/components/css/AvailabilityPicker.css | 48 +----- frontend/src/pages/ExistingEventPage.tsx | 52 +++---- frontend/src/types/Availabilities.tsx | 32 +++- 6 files changed, 146 insertions(+), 160 deletions(-) diff --git a/frontend/src/components/AvailabilityPicker.tsx b/frontend/src/components/AvailabilityPicker.tsx index dd07906..7732392 100644 --- a/frontend/src/components/AvailabilityPicker.tsx +++ b/frontend/src/components/AvailabilityPicker.tsx @@ -1,7 +1,7 @@ import { Box, Stack } from "@mui/material"; import { MouseEvent, useState } from "react"; import "./css/AvailabilityPicker.css"; -import { AvailabilityDay, OthersDay, OthersDays } from "../types/Availabilities"; +import { AvailabilityDay, UserAvailabilityHeatmap } from "../types/Availabilities"; import utils from "../utils"; import AvailabilityPickerDay from "./AvailabilityPickerDay"; import dayjs, { Dayjs } from "dayjs"; @@ -25,7 +25,7 @@ type GhostPreviewProps = { const AvailabilityPicker = (props: { days: AvailabilityDay[], setDays: (days: AvailabilityDay[]) => void, - othersAvailabilities: OthersDays[], + availabilityHeatmap: UserAvailabilityHeatmap, eventType: String, availabilityDurationInMinutes: number, }) => { @@ -64,6 +64,14 @@ const AvailabilityPicker = (props: { day.availableTimes.splice(existingTime, 1); + let dayIndex = props.days.findIndex(d => d.forDate.unix() === day.forDate.unix()); + + if (dayIndex === undefined) { + return; + } else { + props.days.splice(dayIndex, 1, {...day}); + } + props.setDays([...props.days]); } } @@ -81,66 +89,73 @@ const AvailabilityPicker = (props: { let existingTimeContainingFrom = day.availableTimes.findIndex(t => utils.dayjsIsBetweenUnixInclusive(t.fromTime, fromTime, t.toTime)); let existingTimeContainingTo = day.availableTimes.findIndex(t => utils.dayjsIsBetweenUnixInclusive(t.fromTime, toTime, t.toTime)); - // the newly created availability crosses another single one. Both have the same from and to. Do nothing. - if (existingTimeContainingFrom >= 0 && existingTimeContainingTo >= 0 && existingTimeContainingFrom === existingTimeContainingTo) { - return; + switch (true) { + // the newly created availability crosses another single one. Both have the same from and to. Do nothing. + case (existingTimeContainingFrom >= 0 && existingTimeContainingTo >= 0 && existingTimeContainingFrom === existingTimeContainingTo): { + return; + } + // the newly created availability crosses 2 existing ones. Combine all of them into a single one. + case (existingTimeContainingFrom >= 0 && existingTimeContainingTo >= 0 && existingTimeContainingFrom !== existingTimeContainingTo): { + + let newFrom = day.availableTimes[existingTimeContainingFrom].fromTime; + let newTo = day.availableTimes[existingTimeContainingTo].toTime; + + day.availableTimes.splice(existingTimeContainingFrom, 1); + day.availableTimes.splice(existingTimeContainingTo, 1); + + day.availableTimes.push({ + fromTime: newFrom, + toTime: newTo + }); + + break; + } + // The newly created availability from is within an existing one. Combine the 2 into one. + case (existingTimeContainingFrom >= 0 && existingTimeContainingTo < 0): { + + let newFrom = day.availableTimes[existingTimeContainingFrom].fromTime; + + day.availableTimes.splice(existingTimeContainingFrom, 1); + + day.availableTimes.push({ + fromTime: newFrom, + toTime: toTime + }); + + break; + } + // The newly created availability to is within an existing one. Combine the 2 into one. + case (existingTimeContainingFrom < 0 && existingTimeContainingTo >= 0): { + + let newTo = day.availableTimes[existingTimeContainingTo].toTime; + + day.availableTimes.splice(existingTimeContainingTo, 1); + + day.availableTimes.push({ + fromTime: fromTime, + toTime: newTo + }); + + break; + } + default: { + day.availableTimes.push({ + fromTime: fromTime, + toTime: toTime + }); + + break; + } } - // the newly created availability crosses 2 existing ones. Combine all of them into a single one. - if (existingTimeContainingFrom >= 0 && existingTimeContainingTo >= 0 && existingTimeContainingFrom !== existingTimeContainingTo) { - let newFrom = day.availableTimes[existingTimeContainingFrom].fromTime; - let newTo = day.availableTimes[existingTimeContainingTo].toTime; - - day.availableTimes.splice(existingTimeContainingFrom, 1); - day.availableTimes.splice(existingTimeContainingTo, 1); - - day.availableTimes.push({ - fromTime: newFrom, - toTime: newTo - }); - - props.setDays([...props.days]); + let dayIndex = props.days.findIndex(d => d.forDate.unix() === day.forDate.unix()); + if (dayIndex === undefined) { return; + } else { + props.days.splice(dayIndex, 1, {...day}); } - // The newly created availability from is within an existing one. Combine the 2 into one. - if (existingTimeContainingFrom >= 0 && existingTimeContainingTo < 0) { - let newFrom = day.availableTimes[existingTimeContainingFrom].fromTime; - - day.availableTimes.splice(existingTimeContainingFrom, 1); - - day.availableTimes.push({ - fromTime: newFrom, - toTime: toTime - }); - - props.setDays([...props.days]); - - return; - } - - // The newly created availability to is within an existing one. Combine the 2 into one. - if (existingTimeContainingFrom < 0 && existingTimeContainingTo >= 0) { - let newTo = day.availableTimes[existingTimeContainingTo].toTime; - - day.availableTimes.splice(existingTimeContainingTo, 1); - - day.availableTimes.push({ - fromTime: fromTime, - toTime: newTo - }); - - props.setDays([...props.days]); - - return; - } - - day.availableTimes.push({ - fromTime: fromTime, - toTime: toTime - }); - props.setDays([...props.days]); } @@ -162,14 +177,10 @@ const AvailabilityPicker = (props: { { - return { - userName: a.userName, - availableTimes: a.days.find(d => d.forDate.unix() === day.forDate.unix())?.availableTimes ?? [] - } as OthersDay; - })} + availabilityHeatmap={props.availabilityHeatmap} onMouseEnterHalfhour={(e: MouseEvent, time: dayjs.Dayjs) => { displayGhostPeriod(e, time); }} diff --git a/frontend/src/components/AvailabilityPickerDay.tsx b/frontend/src/components/AvailabilityPickerDay.tsx index 0bc9eae..4e6e2bc 100644 --- a/frontend/src/components/AvailabilityPickerDay.tsx +++ b/frontend/src/components/AvailabilityPickerDay.tsx @@ -1,4 +1,4 @@ -import { AvailabilityDay, OthersDay } from "../types/Availabilities"; +import { AvailabilityDay, UserAvailabilityHeatmap } from "../types/Availabilities"; import utils from "../utils"; import { Box, Card, Divider, Typography } from "@mui/material"; import { EventTypes } from "../types/Event"; @@ -8,6 +8,7 @@ import dayjs from "dayjs"; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; import localizedFormat from 'dayjs/plugin/localizedFormat'; +import React, { useMemo } from "react"; dayjs.extend(utc) dayjs.extend(timezone) @@ -17,14 +18,15 @@ const AvailabilityPickerDay = (props: { day: AvailabilityDay, eventType: String, halfHourDisplayHeight: number, - othersAvailabilityDay: OthersDay[], + currentTotalRespondents: number, + availabilityHeatmap: UserAvailabilityHeatmap, onMouseEnterHalfhour: (e: React.MouseEvent, time: dayjs.Dayjs) => void, onMouseClickHalfhour: (day: AvailabilityDay, time: dayjs.Dayjs, isDelete: boolean) => void }) => { - const generateHours = (): JSX.Element[] => { + const generateHours = useMemo((): JSX.Element[] => { let hours: JSX.Element[] = []; - + for (var i = 0; i < 24; i++) { let fullHourTime = props.day.forDate.set("hour", i); let halfHourTime = fullHourTime.add(30, "minutes"); @@ -34,8 +36,9 @@ const AvailabilityPickerDay = (props: { key={fullHourTime.unix()} dateTime={fullHourTime} halfHourDisplayHeight={props.halfHourDisplayHeight} - namesMarkedFullHourAsAvailable={props.othersAvailabilityDay.filter(d => d.availableTimes.some(t => utils.dayjsIsBetweenUnixExclusive(t.fromTime, fullHourTime, t.toTime))).map(d => d.userName)} - namesMarkedHalfHourAsAvailable={props.othersAvailabilityDay.filter(d => d.availableTimes.some(t => utils.dayjsIsBetweenUnixExclusive(t.fromTime, halfHourTime, t.toTime))).map(d => d.userName)} + currentTotalRespondents={props.currentTotalRespondents} + namesMarkedFullHourAsAvailable={props.availabilityHeatmap.getNamesAt(fullHourTime.unix())} + namesMarkedHalfHourAsAvailable={props.availabilityHeatmap.getNamesAt(fullHourTime.add(30, "minutes").unix())} isFullHourSelected={props.day.availableTimes.some(a => utils.dayjsIsBetweenUnixExclusive(a.fromTime, fullHourTime, a.toTime))} isHalfHourSelected={props.day.availableTimes.some(a => utils.dayjsIsBetweenUnixExclusive(a.fromTime, halfHourTime, a.toTime))} onMouseEnterHalfhour={(e: React.MouseEvent, time: dayjs.Dayjs): void => { @@ -49,7 +52,7 @@ const AvailabilityPickerDay = (props: { } return hours; - } + }, [props.day]); return ( - {generateHours()} + {generateHours} ); -} +}; export default AvailabilityPickerDay; \ No newline at end of file diff --git a/frontend/src/components/AvailabilityPickerHour.tsx b/frontend/src/components/AvailabilityPickerHour.tsx index 28a1135..37087a8 100644 --- a/frontend/src/components/AvailabilityPickerHour.tsx +++ b/frontend/src/components/AvailabilityPickerHour.tsx @@ -17,6 +17,7 @@ const AvailabilityPickerHour = (props: { isFullHourSelected: boolean, isHalfHourSelected: boolean, halfHourDisplayHeight: number, + currentTotalRespondents: number, namesMarkedFullHourAsAvailable: String[], namesMarkedHalfHourAsAvailable: String[], onMouseEnterHalfhour: (e: React.MouseEvent, time: Dayjs) => void, @@ -26,6 +27,15 @@ const AvailabilityPickerHour = (props: { return `${names.length} ${names.length > 1 ? "people have" : "person has"} marked this time as available: ${names.join(", ")}`; } + const heatMapColorforValue = (value: number) => { + if (value === 0 || props.currentTotalRespondents === 0) { + return 'inherit'; + } + + var h = (1.0 - (value / props.currentTotalRespondents)) * 240 + return "hsl(" + h + ", 75%, 35%)"; + } + return ( props.onMouseEnterHalfhour(e, props.dateTime)} onMouseDown={(e) => { @@ -67,7 +78,8 @@ const AvailabilityPickerHour = (props: { enterDelay={500} > props.onMouseEnterHalfhour(e, props.dateTime.add(30, "minutes"))} onMouseDown={(e) => { diff --git a/frontend/src/components/css/AvailabilityPicker.css b/frontend/src/components/css/AvailabilityPicker.css index e6a92e2..4c219fe 100644 --- a/frontend/src/components/css/AvailabilityPicker.css +++ b/frontend/src/components/css/AvailabilityPicker.css @@ -15,50 +15,6 @@ } -div[class*=" hour-available-"]{ - background-color: #bb00bb; -} - -div.hour-available-0 { - background-color: inherit; -} - -div.hour-available-1 { - background-color: #330033; -} - -div.hour-available-2 { - background-color: #440044; -} - -div.hour-available-3 { - background-color: #550055; -} - -div.hour-available-4 { - background-color: #660066; -} - -div.hour-available-5 { - background-color: #770077; -} - -div.hour-available-6 { - background-color: #880088; -} - -div.hour-available-7 { - background-color: #990099; -} - -div.hour-available-8 { - background-color: #aa00aa; -} - -div.hour-available-9 { - background-color: #bb00bb; -} - div.hour-light { width: 100%; border-top: solid 1px; @@ -87,9 +43,9 @@ div.selected-availability { background-color: var(--currently-selected-color); } -div.full-hour:hover, div.half-hour:hover { +/* div.full-hour:hover, div.half-hour:hover { background-color: var(--hover-color); -} +} */ div.full-hour:active, div.half-hour:active { background-color: var(--active-color); diff --git a/frontend/src/pages/ExistingEventPage.tsx b/frontend/src/pages/ExistingEventPage.tsx index a13add2..9ae7ce9 100644 --- a/frontend/src/pages/ExistingEventPage.tsx +++ b/frontend/src/pages/ExistingEventPage.tsx @@ -10,7 +10,7 @@ import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; import localizedFormat from 'dayjs/plugin/localizedFormat'; import duration from 'dayjs/plugin/duration'; -import { AvailabilityDay, AvailabilityTime, OthersDays } from "../types/Availabilities"; +import { AvailabilityDay, UserAvailabilityHeatmap } from "../types/Availabilities"; import toast from "react-hot-toast"; dayjs.extend(utc) @@ -26,11 +26,13 @@ export default function ExistingEventPage() { const [canSubmit, setCanSubmit] = useState(false); const [event, setEvent] = useState(createEvent()); const [days, setDays] = useState([]); - const [othersDays, setOthersDays] = useState([]); + const [availabilityHeatmap, setAvailabilityHeatmap] = useState(); const [userName, setUserName] = useState(undefined); useEffect(() => { utils.showSpinner(); + + let localTimezone = dayjs.tz.guess(); Promise.all([ utils.performRequest(`/api/events/${eventId}`) @@ -47,41 +49,23 @@ export default function ExistingEventPage() { utils.performRequest(`/api/events/${eventId}/availabilities`) .then((result: [{ id: number, from_date: string, to_date: string, user_name: string }]) => { - let othersDays: OthersDays[] = []; - let localTimezone = dayjs.tz.guess(); + let heatmap = new UserAvailabilityHeatmap(); + + const LENGTH_OF_30_MINUTES_IN_SECONDS = 1800; for (const availability of result) { - let fromDate = dayjs(availability.from_date).tz(localTimezone); - let toDate = dayjs(availability.to_date).tz(localTimezone); + let start = dayjs(availability.from_date).tz(localTimezone); + let end = dayjs(availability.to_date).tz(localTimezone); - var userTimes = othersDays.find(d => d.userName === availability.user_name); + let startUnix = start.unix(); + let endUnix = end.unix(); - if (!userTimes) { - userTimes = { userName: availability.user_name, days: []} as OthersDays; - - othersDays.push(userTimes); + for (var timeInUnix = startUnix; timeInUnix <= endUnix; timeInUnix += LENGTH_OF_30_MINUTES_IN_SECONDS) { + heatmap.addName(timeInUnix, availability.user_name); } - - let availabilityDay = fromDate.hour(0).minute(0).second(0).millisecond(0); - - var day = userTimes.days.find(d => d.forDate.unix() === availabilityDay.unix()); - - if (!day) { - day = { - forDate: availabilityDay, - availableTimes: [] - } as AvailabilityDay; - - userTimes.days.push(day); - } - - day.availableTimes.push({ - fromTime: fromDate, - toTime: toDate - } as AvailabilityTime) } - - setOthersDays(othersDays); + + setAvailabilityHeatmap(heatmap); }) .catch(e => toast.error(e)) ]) @@ -214,17 +198,17 @@ export default function ExistingEventPage() { { - (event.fromDate !== null && event.toDate !== null && event.eventType !== null) && + (event.fromDate !== null && event.toDate !== null && event.eventType !== null && availabilityHeatmap) && setDays(days)} - othersAvailabilities={othersDays} + availabilityHeatmap={availabilityHeatmap} eventType={event.eventType} availabilityDurationInMinutes={event.duration} /> } - Left-click to select when you're available, right-click to remove the highlighted hours. + Date and times are in your local timezone. Left-click to select when you're available, right-click to remove the highlighted hours. diff --git a/frontend/src/types/Availabilities.tsx b/frontend/src/types/Availabilities.tsx index 1438ea3..f012c89 100644 --- a/frontend/src/types/Availabilities.tsx +++ b/frontend/src/types/Availabilities.tsx @@ -10,12 +10,32 @@ export type AvailabilityDay = { availableTimes: AvailabilityTime[] } -export type OthersDays = { - userName: String, - days: AvailabilityDay[] +export type UserAvailabilityHeatmapValue = { + usersAvailableAtTime: String[] } -export type OthersDay = { - userName: String, - availableTimes: AvailabilityTime[] +export class UserAvailabilityHeatmap { + private map: UserAvailabilityHeatmapValue[]; + public maxNumberOfRespondents: number; + + constructor() { + this.map = []; + this.maxNumberOfRespondents = 0; + } + + addName(unixTime: number, name: String): void { + if (this.map[unixTime] === undefined || this.map[unixTime] === null) { + this.map[unixTime] = { usersAvailableAtTime: [] } as UserAvailabilityHeatmapValue; + } + + this.map[unixTime].usersAvailableAtTime.push(name); + + if (this.map[unixTime].usersAvailableAtTime.length > this.maxNumberOfRespondents) { + this.maxNumberOfRespondents = this.map[unixTime].usersAvailableAtTime.length; + } + } + + getNamesAt(unixTime: number): String[] { + return this.map[unixTime]?.usersAvailableAtTime ?? []; + } } \ No newline at end of file