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 { Box, Stack } from "@mui/material";
import { MouseEvent, useState } from "react"; import { MouseEvent, useState } from "react";
import "./css/AvailabilityPicker.css"; import "./css/AvailabilityPicker.css";
import { AvailabilityDay, OthersDay, OthersDays } from "../types/Availabilities"; import { AvailabilityDay, UserAvailabilityHeatmap } from "../types/Availabilities";
import utils from "../utils"; import utils from "../utils";
import AvailabilityPickerDay from "./AvailabilityPickerDay"; import AvailabilityPickerDay from "./AvailabilityPickerDay";
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
@ -25,7 +25,7 @@ type GhostPreviewProps = {
const AvailabilityPicker = (props: { const AvailabilityPicker = (props: {
days: AvailabilityDay[], days: AvailabilityDay[],
setDays: (days: AvailabilityDay[]) => void, setDays: (days: AvailabilityDay[]) => void,
othersAvailabilities: OthersDays[], availabilityHeatmap: UserAvailabilityHeatmap,
eventType: String, eventType: String,
availabilityDurationInMinutes: number, availabilityDurationInMinutes: number,
}) => { }) => {
@ -64,6 +64,14 @@ const AvailabilityPicker = (props: {
day.availableTimes.splice(existingTime, 1); 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]); 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 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)); 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. switch (true) {
if (existingTimeContainingFrom >= 0 && existingTimeContainingTo >= 0 && existingTimeContainingFrom === existingTimeContainingTo) { // the newly created availability crosses another single one. Both have the same from and to. Do nothing.
return; 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. let dayIndex = props.days.findIndex(d => d.forDate.unix() === day.forDate.unix());
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]);
if (dayIndex === undefined) {
return; 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]); props.setDays([...props.days]);
} }
@ -163,13 +178,9 @@ const AvailabilityPicker = (props: {
key={day.forDate.unix()} key={day.forDate.unix()}
day={day} day={day}
eventType={props.eventType} eventType={props.eventType}
currentTotalRespondents={props.availabilityHeatmap.maxNumberOfRespondents}
halfHourDisplayHeight={HALFHOUR_DISPLAY_HEIGHT} halfHourDisplayHeight={HALFHOUR_DISPLAY_HEIGHT}
othersAvailabilityDay={props.othersAvailabilities.map(a => { availabilityHeatmap={props.availabilityHeatmap}
return {
userName: a.userName,
availableTimes: a.days.find(d => d.forDate.unix() === day.forDate.unix())?.availableTimes ?? []
} as OthersDay;
})}
onMouseEnterHalfhour={(e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: dayjs.Dayjs) => { onMouseEnterHalfhour={(e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: dayjs.Dayjs) => {
displayGhostPeriod(e, time); 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 utils from "../utils";
import { Box, Card, Divider, Typography } from "@mui/material"; import { Box, Card, Divider, Typography } from "@mui/material";
import { EventTypes } from "../types/Event"; import { EventTypes } from "../types/Event";
@ -8,6 +8,7 @@ import dayjs from "dayjs";
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone'; import timezone from 'dayjs/plugin/timezone';
import localizedFormat from 'dayjs/plugin/localizedFormat'; import localizedFormat from 'dayjs/plugin/localizedFormat';
import React, { useMemo } from "react";
dayjs.extend(utc) dayjs.extend(utc)
dayjs.extend(timezone) dayjs.extend(timezone)
@ -17,12 +18,13 @@ const AvailabilityPickerDay = (props: {
day: AvailabilityDay, day: AvailabilityDay,
eventType: String, eventType: String,
halfHourDisplayHeight: number, halfHourDisplayHeight: number,
othersAvailabilityDay: OthersDay[], currentTotalRespondents: number,
availabilityHeatmap: UserAvailabilityHeatmap,
onMouseEnterHalfhour: (e: React.MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: dayjs.Dayjs) => void, onMouseEnterHalfhour: (e: React.MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: dayjs.Dayjs) => void,
onMouseClickHalfhour: (day: AvailabilityDay, time: dayjs.Dayjs, isDelete: boolean) => void onMouseClickHalfhour: (day: AvailabilityDay, time: dayjs.Dayjs, isDelete: boolean) => void
}) => { }) => {
const generateHours = (): JSX.Element[] => { const generateHours = useMemo((): JSX.Element[] => {
let hours: JSX.Element[] = []; let hours: JSX.Element[] = [];
for (var i = 0; i < 24; i++) { for (var i = 0; i < 24; i++) {
@ -34,8 +36,9 @@ const AvailabilityPickerDay = (props: {
key={fullHourTime.unix()} key={fullHourTime.unix()}
dateTime={fullHourTime} dateTime={fullHourTime}
halfHourDisplayHeight={props.halfHourDisplayHeight} halfHourDisplayHeight={props.halfHourDisplayHeight}
namesMarkedFullHourAsAvailable={props.othersAvailabilityDay.filter(d => d.availableTimes.some(t => utils.dayjsIsBetweenUnixExclusive(t.fromTime, fullHourTime, t.toTime))).map(d => d.userName)} currentTotalRespondents={props.currentTotalRespondents}
namesMarkedHalfHourAsAvailable={props.othersAvailabilityDay.filter(d => d.availableTimes.some(t => utils.dayjsIsBetweenUnixExclusive(t.fromTime, halfHourTime, t.toTime))).map(d => d.userName)} 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))} 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))} isHalfHourSelected={props.day.availableTimes.some(a => utils.dayjsIsBetweenUnixExclusive(a.fromTime, halfHourTime, a.toTime))}
onMouseEnterHalfhour={(e: React.MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: dayjs.Dayjs): void => { onMouseEnterHalfhour={(e: React.MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: dayjs.Dayjs): void => {
@ -49,7 +52,7 @@ const AvailabilityPickerDay = (props: {
} }
return hours; return hours;
} }, [props.day]);
return ( return (
<Card <Card
@ -84,10 +87,10 @@ const AvailabilityPickerDay = (props: {
<Divider></Divider> <Divider></Divider>
{generateHours()} {generateHours}
</Card> </Card>
); );
} };
export default AvailabilityPickerDay; export default AvailabilityPickerDay;

View file

@ -17,6 +17,7 @@ const AvailabilityPickerHour = (props: {
isFullHourSelected: boolean, isFullHourSelected: boolean,
isHalfHourSelected: boolean, isHalfHourSelected: boolean,
halfHourDisplayHeight: number, halfHourDisplayHeight: number,
currentTotalRespondents: number,
namesMarkedFullHourAsAvailable: String[], namesMarkedFullHourAsAvailable: String[],
namesMarkedHalfHourAsAvailable: String[], namesMarkedHalfHourAsAvailable: String[],
onMouseEnterHalfhour: (e: React.MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: Dayjs) => void, 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(", ")}`; 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 ( return (
<Box <Box
//className={classNames({ "hour-light": isEvenHour, "hour-dark": !isEvenHour })} //className={classNames({ "hour-light": isEvenHour, "hour-dark": !isEvenHour })}
@ -41,7 +51,8 @@ const AvailabilityPickerHour = (props: {
enterDelay={500} enterDelay={500}
> >
<Box <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} height={props.halfHourDisplayHeight}
onMouseEnter={(e) => props.onMouseEnterHalfhour(e, props.dateTime)} onMouseEnter={(e) => props.onMouseEnterHalfhour(e, props.dateTime)}
onMouseDown={(e) => { onMouseDown={(e) => {
@ -67,7 +78,8 @@ const AvailabilityPickerHour = (props: {
enterDelay={500} enterDelay={500}
> >
<Box <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} height={props.halfHourDisplayHeight}
onMouseEnter={(e) => props.onMouseEnterHalfhour(e, props.dateTime.add(30, "minutes"))} onMouseEnter={(e) => props.onMouseEnterHalfhour(e, props.dateTime.add(30, "minutes"))}
onMouseDown={(e) => { 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 { div.hour-light {
width: 100%; width: 100%;
border-top: solid 1px; border-top: solid 1px;
@ -87,9 +43,9 @@ div.selected-availability {
background-color: var(--currently-selected-color); 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); background-color: var(--hover-color);
} } */
div.full-hour:active, div.half-hour:active { div.full-hour:active, div.half-hour:active {
background-color: var(--active-color); background-color: var(--active-color);

View file

@ -10,7 +10,7 @@ import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone'; import timezone from 'dayjs/plugin/timezone';
import localizedFormat from 'dayjs/plugin/localizedFormat'; import localizedFormat from 'dayjs/plugin/localizedFormat';
import duration from 'dayjs/plugin/duration'; 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"; import toast from "react-hot-toast";
dayjs.extend(utc) dayjs.extend(utc)
@ -26,12 +26,14 @@ export default function ExistingEventPage() {
const [canSubmit, setCanSubmit] = useState<boolean>(false); const [canSubmit, setCanSubmit] = useState<boolean>(false);
const [event, setEvent] = useState<Event>(createEvent()); const [event, setEvent] = useState<Event>(createEvent());
const [days, setDays] = useState<AvailabilityDay[]>([]); const [days, setDays] = useState<AvailabilityDay[]>([]);
const [othersDays, setOthersDays] = useState<OthersDays[]>([]); const [availabilityHeatmap, setAvailabilityHeatmap] = useState<UserAvailabilityHeatmap | undefined>();
const [userName, setUserName] = useState<String | undefined>(undefined); const [userName, setUserName] = useState<String | undefined>(undefined);
useEffect(() => { useEffect(() => {
utils.showSpinner(); utils.showSpinner();
let localTimezone = dayjs.tz.guess();
Promise.all([ Promise.all([
utils.performRequest(`/api/events/${eventId}`) utils.performRequest(`/api/events/${eventId}`)
.then(result => setEvent({ .then(result => setEvent({
@ -47,41 +49,23 @@ export default function ExistingEventPage() {
utils.performRequest(`/api/events/${eventId}/availabilities`) utils.performRequest(`/api/events/${eventId}/availabilities`)
.then((result: [{ id: number, from_date: string, to_date: string, user_name: string }]) => { .then((result: [{ id: number, from_date: string, to_date: string, user_name: string }]) => {
let othersDays: OthersDays[] = []; let heatmap = new UserAvailabilityHeatmap();
let localTimezone = dayjs.tz.guess();
const LENGTH_OF_30_MINUTES_IN_SECONDS = 1800;
for (const availability of result) { for (const availability of result) {
let fromDate = dayjs(availability.from_date).tz(localTimezone); let start = dayjs(availability.from_date).tz(localTimezone);
let toDate = dayjs(availability.to_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) { for (var timeInUnix = startUnix; timeInUnix <= endUnix; timeInUnix += LENGTH_OF_30_MINUTES_IN_SECONDS) {
userTimes = { userName: availability.user_name, days: []} as OthersDays; heatmap.addName(timeInUnix, availability.user_name);
othersDays.push(userTimes);
} }
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)) .catch(e => toast.error(e))
]) ])
@ -214,17 +198,17 @@ export default function ExistingEventPage() {
</Grid> </Grid>
<Grid xs={12}> <Grid xs={12}>
{ {
(event.fromDate !== null && event.toDate !== null && event.eventType !== null) && (event.fromDate !== null && event.toDate !== null && event.eventType !== null && availabilityHeatmap) &&
<AvailabilityPicker <AvailabilityPicker
days={days} days={days}
setDays={(days) => setDays(days)} setDays={(days) => setDays(days)}
othersAvailabilities={othersDays} availabilityHeatmap={availabilityHeatmap}
eventType={event.eventType} eventType={event.eventType}
availabilityDurationInMinutes={event.duration} availabilityDurationInMinutes={event.duration}
/> />
} }
<Typography pt={1} fontSize={"0.65em"}> <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> </Typography>
</Grid> </Grid>
<Grid xs={0} md={3}></Grid> <Grid xs={0} md={3}></Grid>

View file

@ -10,12 +10,32 @@ export type AvailabilityDay = {
availableTimes: AvailabilityTime[] availableTimes: AvailabilityTime[]
} }
export type OthersDays = { export type UserAvailabilityHeatmapValue = {
userName: String, usersAvailableAtTime: String[]
days: AvailabilityDay[]
} }
export type OthersDay = { export class UserAvailabilityHeatmap {
userName: String, private map: UserAvailabilityHeatmapValue[];
availableTimes: AvailabilityTime[] 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 ?? [];
}
} }