From a2bb7fab1cec49d5455aaeefa715a3ce602da2e8 Mon Sep 17 00:00:00 2001 From: mvvasilev Date: Wed, 10 Jan 2024 00:18:42 +0200 Subject: [PATCH] New availability picker --- .../src/components/AvailabilityPicker.tsx | 295 +++++++++--------- .../src/components/css/AvailabilityPicker.css | 46 +++ 2 files changed, 202 insertions(+), 139 deletions(-) create mode 100644 frontend/src/components/css/AvailabilityPicker.css diff --git a/frontend/src/components/AvailabilityPicker.tsx b/frontend/src/components/AvailabilityPicker.tsx index 0b1554f..86fa02b 100644 --- a/frontend/src/components/AvailabilityPicker.tsx +++ b/frontend/src/components/AvailabilityPicker.tsx @@ -1,11 +1,13 @@ -import { Box, Card, Divider, Stack, Typography } from "@mui/material"; +import { Box, Card, Divider, Stack, Theme, Typography, useTheme } from "@mui/material"; import dayjs, { Dayjs } from "dayjs"; -import { useEffect, useState } from "react"; +import { MouseEvent, ReactNode, 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 { alpha } from '@material-ui/core/styles/colorManipulator'; dayjs.extend(utc) dayjs.extend(timezone) @@ -21,6 +23,13 @@ type AvailabilityDay = { availableTimes: AvailabilityTime[] } +type GhostPreviewProps = { + top: number, + left: number, + width: String, + height: String +} + const HALFHOUR_DISPLAY_HEIGHT: number = 15; const DAY_DISPLAY_WIDTH: String = "150px"; @@ -31,9 +40,10 @@ export default function AvailabilityPicker(props: { eventType: String, availabilityDurationInMinutes: number }) { + const theme: Theme = useTheme(); + const [days, setDays] = useState([]); - const [selectingAvailabilityForDay, setAvailabilityDayBeingSelectedFor] = useState(null); - const [currentAvailabilityTime, setAvailabilityTime] = useState(null); + const [ghostPreviewProps, setGhostPreviewProps] = useState(); useEffect(() => { let localTimezone = dayjs.tz.guess(); @@ -61,10 +71,6 @@ export default function AvailabilityPicker(props: { } }, [props]); - useEffect(() => { - console.log(days) - }, [days]) - function createAvailabilitiesBasedOnUnspecifiedInitialDate(numberOfDays: number, tz: string) { createAvailabilitiesBasedOnInitialDate(dayjs.tz("1970-01-05 00:00:00", tz), numberOfDays); } @@ -84,131 +90,117 @@ export default function AvailabilityPicker(props: { setDays(availabilities); } - function clearAvailabilityTimeSelection() { - setAvailabilityDayBeingSelectedFor(null); - setAvailabilityTime(null); + function displayGhostPeriod(e: React.MouseEvent, time: Dayjs) { + let timeInMinutes = (time.hour() * 60) + time.minute(); + let timeLeftInDayLessThanDuration = (timeInMinutes + props.availabilityDurationInMinutes) > 24 * 60; + + if (timeLeftInDayLessThanDuration) { + return; + } + + let scrollTop = document.getElementById('availability-picker')?.scrollTop ?? 0; + let scrollLeft = document.getElementById('availability-picker')?.scrollLeft ?? 0; + + const element = e.target.getBoundingClientRect(); + + setGhostPreviewProps({ + top: e.target?.offsetTop - scrollTop, + left: e.target?.offsetLeft - scrollLeft, + width: element.width, + height: `${(props.availabilityDurationInMinutes/60) * 2 * HALFHOUR_DISPLAY_HEIGHT}px` + }) } - function beginAvailabilityTimeSelection(e: React.MouseEvent, day: AvailabilityDay, startTime: Dayjs) { - setAvailabilityDayBeingSelectedFor(day); - setAvailabilityTime({ - fromTime: startTime, - toTime: startTime + function createAvailability(day: AvailabilityDay, time: Dayjs) { + let fromTime = time; + let toTime = time.add(props.availabilityDurationInMinutes, "minutes"); + + let existingTimeContainingFrom = day.availableTimes.findIndex(t => (t.fromTime.isBefore(fromTime) || t.fromTime.isSame(fromTime)) && (t.toTime.isAfter(fromTime) || t.toTime.isSame(fromTime))); + let existingTimeContainingTo = day.availableTimes.findIndex(t => (t.fromTime.isBefore(toTime) || t.fromTime.isSame(toTime)) && (t.toTime.isAfter(toTime) || t.toTime.isSame(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; + } + + // 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[existingTimeContainingFrom].toTime; + + day.availableTimes.splice(existingTimeContainingFrom); + day.availableTimes.splice(existingTimeContainingTo); + + day.availableTimes.push({ + fromTime: newFrom, + toTime: newTo + }); + + return; + } + + // 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); + + day.availableTimes.push({ + fromTime: newFrom, + toTime: toTime + }); + + 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[existingTimeContainingFrom].toTime; + + day.availableTimes.splice(existingTimeContainingFrom); + + day.availableTimes.push({ + fromTime: fromTime, + toTime: newTo + }); + + return; + } + + day.availableTimes.push({ + fromTime: fromTime, + toTime: toTime }); - } - function finishAvailabilityTimeSelection(e: React.MouseEvent, day: AvailabilityDay) { - if (currentAvailabilityTime === null) { - return; - } - - day.availableTimes.push(currentAvailabilityTime); setDays([...days]) - - clearAvailabilityTimeSelection(); } - function addTimeToAvailabilityTimeSelection(e: React.MouseEvent, day: AvailabilityDay, time: Dayjs) { - if (e.buttons !== 1) { - return; - } - - if (currentAvailabilityTime === null) { - return; - } - - if (currentAvailabilityTime !== null && selectingAvailabilityForDay !== null && Math.abs(selectingAvailabilityForDay.forDate.diff(time, "day")) >= 1) { - clearAvailabilityTimeSelection(); - return; - } - - let currentFrom = currentAvailabilityTime.fromTime; - let currentTo = currentAvailabilityTime.toTime; - - if (time.isBefore(currentFrom)) { - setAvailabilityTime({ - fromTime: time, - toTime: currentTo - }) - - return; - } - - if (time.isAfter(currentTo)) { - setAvailabilityTime({ - fromTime: currentFrom, - toTime: time - }) - - return; - } - } - - function currentAvailabilityTimeSelectionIncludes(time: Dayjs): boolean { - if (currentAvailabilityTime === null) { - return false; - } - - if ((time.isAfter(currentAvailabilityTime.fromTime) && time.isBefore(currentAvailabilityTime.toTime)) || (time.isSame(currentAvailabilityTime.toTime) || time.isSame(currentAvailabilityTime.fromTime))) { - return true; - } - - return false; - } - - function isTimeIncludedInAnyAvailabilityPeriod(day: AvailabilityDay, time: Dayjs): boolean { - return day.availableTimes.some(t => t.fromTime.isBefore(time) && t.toTime.isAfter(time)); - } - - function isTimeBeginningOfAnyAvailabilityPeriod(day: AvailabilityDay, time: Dayjs): boolean { - return day.availableTimes.some(t => t.fromTime.isSame(time)); - } - - function isTimeEndingOfAnyAvailabilityPeriod(day: AvailabilityDay, time: Dayjs): boolean { - return day.availableTimes.some(t => t.toTime.isSame(time)); + 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 HOVER_COLOR: String = "#004455"; - const HOUR_LIGHT_COLOR: String = "#002233"; - const HOUR_DARK_COLOR: String = "#003344"; - const HOUR_BORDER_COLOR: String = "#777"; - const ACTIVE_COLOR: String = "#223300"; - const CURRENTLY_SELECTED_COLOR: String = "#112200"; const HOUR_TEXT_COLOR: String = "#ddd"; - const HALFHOUR_BORDER_COLOR: String = "#333"; - let hours = [...Array(24)].map((_, i) => { + let hours = []; + + for (var i = 0; i < 24; i++) { let time = day.forDate.set("hour", i).set("minute", 0).set("second", 0); - return ( + hours.push( beginAvailabilityTimeSelection(e, day, time)} - onMouseUp={(e) => finishAvailabilityTimeSelection(e, day)} - onMouseOver={(e) => addTimeToAvailabilityTimeSelection(e, day, time)} + className={[ + "full-hour", + isSelectedAvailability(day, time) && "selected-availability" + ]} + height={HALFHOUR_DISPLAY_HEIGHT} + onMouseEnter={(e: MouseEvent) => displayGhostPeriod(e, time)} + onClick={(e) => createAvailability(day, time)} > beginAvailabilityTimeSelection(e, day, time.set("minute", 30))} - onMouseUp={(e) => finishAvailabilityTimeSelection(e, day)} - onMouseOver={(e) => addTimeToAvailabilityTimeSelection(e, day, time.set("minute", 30))} + className={[ + "half-hour", + isSelectedAvailability(day, time) && "selected-availability" + ]} + height={HALFHOUR_DISPLAY_HEIGHT} + onMouseEnter={(e: MouseEvent) => displayGhostPeriod(e, time.add(30, "minutes"))} + onClick={(e) => createAvailability(day, time.add(30, "minutes"))} > - ); - }) + } return ( clearAvailabilityTimeSelection()} > + {hours} @@ -288,21 +275,51 @@ export default function AvailabilityPicker(props: { } return ( - + setGhostPreviewProps(null)} + > + { + days.map(a => generateDay(a)) + } + { - days.map(a => generateDay(a)) + (ghostPreviewProps !== null) && + + } - + ); } \ No newline at end of file diff --git a/frontend/src/components/css/AvailabilityPicker.css b/frontend/src/components/css/AvailabilityPicker.css new file mode 100644 index 0000000..f011ea4 --- /dev/null +++ b/frontend/src/components/css/AvailabilityPicker.css @@ -0,0 +1,46 @@ +:root { + --hover-color: #004455; + --hour-light-color: #003344; + --hour-dark-color: #002233; + --hour-border-bolor: #777; + --hour-text-color: #ddd; + --active-color: #223300; + --currently-selected-color: #112200; + --halfhour-border-color: #333; +} + +div.hour-light { + width: 100%; + border-bottom: solid 1px; + border-color: var(--hour-border-bolor); + background-color: var(--hour-light-color); +} + +div.hour-dark { + width: 100%; + border-bottom: solid 1px; + border-color: var(--hour-border-bolor); + background-color: var(--hour-dark-color); +} + +div.full-hour { + width: 100%; + border-bottom: dashed 1px; + border-color: var(--halfhour-border-color) +} + +div.half-hour { + width: 100%; +} + +div.selected-availability { + background-color: var(--currently-selected-color); +} + +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); +} \ No newline at end of file