Optimize availability picker, use new approach for displaying available times

This commit is contained in:
Miroslav Vasilev 2024-01-12 11:53:39 +02:00
parent 39ed7d8932
commit fe50c21f84
6 changed files with 146 additions and 160 deletions

View file

@ -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: {
<AvailabilityPickerDay
key={day.forDate.unix()}
day={day}
eventType={props.eventType}
eventType={props.eventType}
currentTotalRespondents={props.availabilityHeatmap.maxNumberOfRespondents}
halfHourDisplayHeight={HALFHOUR_DISPLAY_HEIGHT}
othersAvailabilityDay={props.othersAvailabilities.map(a => {
return {
userName: a.userName,
availableTimes: a.days.find(d => d.forDate.unix() === day.forDate.unix())?.availableTimes ?? []
} as OthersDay;
})}
availabilityHeatmap={props.availabilityHeatmap}
onMouseEnterHalfhour={(e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: dayjs.Dayjs) => {
displayGhostPeriod(e, time);
}}

View file

@ -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<HTMLDivElement, globalThis.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<HTMLDivElement, globalThis.MouseEvent>, time: dayjs.Dayjs): void => {
@ -49,7 +52,7 @@ const AvailabilityPickerDay = (props: {
}
return hours;
}
}, [props.day]);
return (
<Card
@ -84,10 +87,10 @@ const AvailabilityPickerDay = (props: {
<Divider></Divider>
{generateHours()}
{generateHours}
</Card>
);
}
};
export default AvailabilityPickerDay;

View file

@ -17,6 +17,7 @@ const AvailabilityPickerHour = (props: {
isFullHourSelected: boolean,
isHalfHourSelected: boolean,
halfHourDisplayHeight: number,
currentTotalRespondents: number,
namesMarkedFullHourAsAvailable: String[],
namesMarkedHalfHourAsAvailable: String[],
onMouseEnterHalfhour: (e: React.MouseEvent<HTMLDivElement, globalThis.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 (
<Box
//className={classNames({ "hour-light": isEvenHour, "hour-dark": !isEvenHour })}
@ -41,7 +51,8 @@ const AvailabilityPickerHour = (props: {
enterDelay={500}
>
<Box
className={classNames("full-hour", `hour-available-${props.namesMarkedFullHourAsAvailable.length}`, { "selected-availability": props.isFullHourSelected })}
className={classNames("full-hour", { "selected-availability": props.isFullHourSelected })}
bgcolor={heatMapColorforValue(props.namesMarkedFullHourAsAvailable.length)}
height={props.halfHourDisplayHeight}
onMouseEnter={(e) => props.onMouseEnterHalfhour(e, props.dateTime)}
onMouseDown={(e) => {
@ -67,7 +78,8 @@ const AvailabilityPickerHour = (props: {
enterDelay={500}
>
<Box
className={classNames("half-hour", `hour-available-${props.namesMarkedHalfHourAsAvailable.length}`, { "selected-availability": props.isHalfHourSelected })}
className={classNames("half-hour", { "selected-availability": props.isHalfHourSelected })}
bgcolor={heatMapColorforValue(props.namesMarkedHalfHourAsAvailable.length)}
height={props.halfHourDisplayHeight}
onMouseEnter={(e) => props.onMouseEnterHalfhour(e, props.dateTime.add(30, "minutes"))}
onMouseDown={(e) => {

View file

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

View file

@ -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<boolean>(false);
const [event, setEvent] = useState<Event>(createEvent());
const [days, setDays] = useState<AvailabilityDay[]>([]);
const [othersDays, setOthersDays] = useState<OthersDays[]>([]);
const [availabilityHeatmap, setAvailabilityHeatmap] = useState<UserAvailabilityHeatmap | undefined>();
const [userName, setUserName] = useState<String | undefined>(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() {
</Grid>
<Grid xs={12}>
{
(event.fromDate !== null && event.toDate !== null && event.eventType !== null) &&
(event.fromDate !== null && event.toDate !== null && event.eventType !== null && availabilityHeatmap) &&
<AvailabilityPicker
days={days}
setDays={(days) => setDays(days)}
othersAvailabilities={othersDays}
availabilityHeatmap={availabilityHeatmap}
eventType={event.eventType}
availabilityDurationInMinutes={event.duration}
/>
}
<Typography pt={1} fontSize={"0.65em"}>
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.
</Typography>
</Grid>
<Grid xs={0} md={3}></Grid>

View file

@ -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 ?? [];
}
}