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<AvailabilityDay[]>([]); - const [selectingAvailabilityForDay, setAvailabilityDayBeingSelectedFor] = useState<AvailabilityDay | null>(null); - const [currentAvailabilityTime, setAvailabilityTime] = useState<AvailabilityTime | null>(null); + const [ghostPreviewProps, setGhostPreviewProps] = useState<GhostPreviewProps | null>(); 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<HTMLDivElement, 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<HTMLDivElement, 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<HTMLDivElement, MouseEvent>, day: AvailabilityDay) { - if (currentAvailabilityTime === null) { - return; - } - - day.availableTimes.push(currentAvailabilityTime); setDays([...days]) - - clearAvailabilityTimeSelection(); } - function addTimeToAvailabilityTimeSelection(e: React.MouseEvent<HTMLDivElement, 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<String>(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( <Box key={`${i}`} - sx={{ - width: "100%", - borderBottom: 1, - borderColor: HOUR_BORDER_COLOR, - bgcolor: (i % 2 == 0) ? HOUR_LIGHT_COLOR : HOUR_DARK_COLOR, - ":hover": { - bgcolor: HOVER_COLOR - } - }} + className={(i % 2 == 0) ? "hour-light" : "hour-dark"} > <Box - sx={{ - width: "100%", - height: HALFHOUR_DISPLAY_HEIGHT, - borderBottom: 1, - borderColor: HALFHOUR_BORDER_COLOR, - ":active": { - bgcolor: ACTIVE_COLOR - }, - bgcolor: currentAvailabilityTimeSelectionIncludes(time) ? CURRENTLY_SELECTED_COLOR : "inherit" - }} - onMouseDown={(e) => 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<HTMLDivElement, MouseEvent>) => displayGhostPeriod(e, time)} + onClick={(e) => createAvailability(day, time)} > <Typography className={"noselect"} @@ -220,23 +212,18 @@ export default function AvailabilityPicker(props: { </Typography> </Box> <Box - sx={{ - width: "100%", - height: HALFHOUR_DISPLAY_HEIGHT, - ":active": { - bgcolor: ACTIVE_COLOR - }, - bgcolor: currentAvailabilityTimeSelectionIncludes(time.set("minute", 30)) ? CURRENTLY_SELECTED_COLOR : "inherit" - }} - onMouseDown={(e) => 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<HTMLDivElement, MouseEvent>) => displayGhostPeriod(e, time.add(30, "minutes"))} + onClick={(e) => createAvailability(day, time.add(30, "minutes"))} > </Box> </Box> - ); - }) + } return ( <Stack @@ -255,7 +242,6 @@ export default function AvailabilityPicker(props: { overflow: "visible" }} variant="outlined" - onMouseLeave={(e) => clearAvailabilityTimeSelection()} > <Box sx={{ width: "100%" }} @@ -281,6 +267,7 @@ export default function AvailabilityPicker(props: { } </Box> <Divider></Divider> + {hours} </Card> </Stack> @@ -288,21 +275,51 @@ export default function AvailabilityPicker(props: { } return ( - <Stack - direction="row" - spacing={1} - justifyContent={"safe center"} + <Box sx={{ + position: "relative", width: "100%", - height: "auto", - maxHeight: "500px", - overflowY: "scroll", - overflowX: "scroll" - }} + height: "auto", + overflow: "hidden" + }} > + <Stack + id="availability-picker" + direction="row" + spacing={1} + justifyContent={"safe center"} + sx={{ + width: "100%", + height: "500px", + overflowY: "scroll", + overflowX: "scroll" + }} + onScroll={(e) => setGhostPreviewProps(null)} + > + { + days.map(a => generateDay(a)) + } + </Stack> { - days.map(a => generateDay(a)) + (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" + }} + > + </Box> } - </Stack> + </Box> ); } \ 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