New availability picker

This commit is contained in:
Miroslav Vasilev 2024-01-10 00:18:42 +02:00
parent 0dcf39b48a
commit a2bb7fab1c
2 changed files with 202 additions and 139 deletions

View file

@ -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 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 utc from 'dayjs/plugin/utc';
import * as timezone from 'dayjs/plugin/timezone'; import * as timezone from 'dayjs/plugin/timezone';
import * as localizedFormat from 'dayjs/plugin/localizedFormat'; import * as localizedFormat from 'dayjs/plugin/localizedFormat';
import utils from "../utils"; import utils from "../utils";
import { EventTypes } from "../types/Event"; import { EventTypes } from "../types/Event";
import "./css/AvailabilityPicker.css";
// import { alpha } from '@material-ui/core/styles/colorManipulator';
dayjs.extend(utc) dayjs.extend(utc)
dayjs.extend(timezone) dayjs.extend(timezone)
@ -21,6 +23,13 @@ type AvailabilityDay = {
availableTimes: AvailabilityTime[] availableTimes: AvailabilityTime[]
} }
type GhostPreviewProps = {
top: number,
left: number,
width: String,
height: String
}
const HALFHOUR_DISPLAY_HEIGHT: number = 15; const HALFHOUR_DISPLAY_HEIGHT: number = 15;
const DAY_DISPLAY_WIDTH: String = "150px"; const DAY_DISPLAY_WIDTH: String = "150px";
@ -31,9 +40,10 @@ export default function AvailabilityPicker(props: {
eventType: String, eventType: String,
availabilityDurationInMinutes: number availabilityDurationInMinutes: number
}) { }) {
const theme: Theme = useTheme();
const [days, setDays] = useState<AvailabilityDay[]>([]); const [days, setDays] = useState<AvailabilityDay[]>([]);
const [selectingAvailabilityForDay, setAvailabilityDayBeingSelectedFor] = useState<AvailabilityDay | null>(null); const [ghostPreviewProps, setGhostPreviewProps] = useState<GhostPreviewProps | null>();
const [currentAvailabilityTime, setAvailabilityTime] = useState<AvailabilityTime | null>(null);
useEffect(() => { useEffect(() => {
let localTimezone = dayjs.tz.guess(); let localTimezone = dayjs.tz.guess();
@ -61,10 +71,6 @@ export default function AvailabilityPicker(props: {
} }
}, [props]); }, [props]);
useEffect(() => {
console.log(days)
}, [days])
function createAvailabilitiesBasedOnUnspecifiedInitialDate(numberOfDays: number, tz: string) { function createAvailabilitiesBasedOnUnspecifiedInitialDate(numberOfDays: number, tz: string) {
createAvailabilitiesBasedOnInitialDate(dayjs.tz("1970-01-05 00:00:00", tz), numberOfDays); createAvailabilitiesBasedOnInitialDate(dayjs.tz("1970-01-05 00:00:00", tz), numberOfDays);
} }
@ -84,131 +90,117 @@ export default function AvailabilityPicker(props: {
setDays(availabilities); setDays(availabilities);
} }
function clearAvailabilityTimeSelection() { function displayGhostPeriod(e: React.MouseEvent<HTMLDivElement, MouseEvent>, time: Dayjs) {
setAvailabilityDayBeingSelectedFor(null); let timeInMinutes = (time.hour() * 60) + time.minute();
setAvailabilityTime(null); let timeLeftInDayLessThanDuration = (timeInMinutes + props.availabilityDurationInMinutes) > 24 * 60;
if (timeLeftInDayLessThanDuration) {
return;
} }
function beginAvailabilityTimeSelection(e: React.MouseEvent<HTMLDivElement, MouseEvent>, day: AvailabilityDay, startTime: Dayjs) { let scrollTop = document.getElementById('availability-picker')?.scrollTop ?? 0;
setAvailabilityDayBeingSelectedFor(day); let scrollLeft = document.getElementById('availability-picker')?.scrollLeft ?? 0;
setAvailabilityTime({
fromTime: startTime, const element = e.target.getBoundingClientRect();
toTime: startTime
setGhostPreviewProps({
top: e.target?.offsetTop - scrollTop,
left: e.target?.offsetLeft - scrollLeft,
width: element.width,
height: `${(props.availabilityDurationInMinutes/60) * 2 * HALFHOUR_DISPLAY_HEIGHT}px`
})
}
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
}); });
}
function finishAvailabilityTimeSelection(e: React.MouseEvent<HTMLDivElement, MouseEvent>, day: AvailabilityDay) {
if (currentAvailabilityTime === null) {
return; return;
} }
day.availableTimes.push(currentAvailabilityTime); // 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
});
setDays([...days]) setDays([...days])
clearAvailabilityTimeSelection();
} }
function addTimeToAvailabilityTimeSelection(e: React.MouseEvent<HTMLDivElement, MouseEvent>, day: AvailabilityDay, time: Dayjs) { function isSelectedAvailability(day: AvailabilityDay, time: Dayjs): boolean {
if (e.buttons !== 1) { return day.availableTimes.some(t => (t.fromTime.isBefore(time) || t.fromTime.isSame(time)) && (t.toTime.isAfter(time) || t.toTime.isSame(time)));
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 generateDay(day: AvailabilityDay) { 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 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); let time = day.forDate.set("hour", i).set("minute", 0).set("second", 0);
return ( hours.push(
<Box <Box
key={`${i}`} key={`${i}`}
sx={{ className={(i % 2 == 0) ? "hour-light" : "hour-dark"}
width: "100%",
borderBottom: 1,
borderColor: HOUR_BORDER_COLOR,
bgcolor: (i % 2 == 0) ? HOUR_LIGHT_COLOR : HOUR_DARK_COLOR,
":hover": {
bgcolor: HOVER_COLOR
}
}}
> >
<Box <Box
sx={{ className={[
width: "100%", "full-hour",
height: HALFHOUR_DISPLAY_HEIGHT, isSelectedAvailability(day, time) && "selected-availability"
borderBottom: 1, ]}
borderColor: HALFHOUR_BORDER_COLOR, height={HALFHOUR_DISPLAY_HEIGHT}
":active": { onMouseEnter={(e: MouseEvent<HTMLDivElement, MouseEvent>) => displayGhostPeriod(e, time)}
bgcolor: ACTIVE_COLOR onClick={(e) => createAvailability(day, time)}
},
bgcolor: currentAvailabilityTimeSelectionIncludes(time) ? CURRENTLY_SELECTED_COLOR : "inherit"
}}
onMouseDown={(e) => beginAvailabilityTimeSelection(e, day, time)}
onMouseUp={(e) => finishAvailabilityTimeSelection(e, day)}
onMouseOver={(e) => addTimeToAvailabilityTimeSelection(e, day, time)}
> >
<Typography <Typography
className={"noselect"} className={"noselect"}
@ -220,23 +212,18 @@ export default function AvailabilityPicker(props: {
</Typography> </Typography>
</Box> </Box>
<Box <Box
sx={{ className={[
width: "100%", "half-hour",
height: HALFHOUR_DISPLAY_HEIGHT, isSelectedAvailability(day, time) && "selected-availability"
":active": { ]}
bgcolor: ACTIVE_COLOR height={HALFHOUR_DISPLAY_HEIGHT}
}, onMouseEnter={(e: MouseEvent<HTMLDivElement, MouseEvent>) => displayGhostPeriod(e, time.add(30, "minutes"))}
bgcolor: currentAvailabilityTimeSelectionIncludes(time.set("minute", 30)) ? CURRENTLY_SELECTED_COLOR : "inherit" onClick={(e) => createAvailability(day, time.add(30, "minutes"))}
}}
onMouseDown={(e) => beginAvailabilityTimeSelection(e, day, time.set("minute", 30))}
onMouseUp={(e) => finishAvailabilityTimeSelection(e, day)}
onMouseOver={(e) => addTimeToAvailabilityTimeSelection(e, day, time.set("minute", 30))}
> >
</Box> </Box>
</Box> </Box>
); );
}) }
return ( return (
<Stack <Stack
@ -255,7 +242,6 @@ export default function AvailabilityPicker(props: {
overflow: "visible" overflow: "visible"
}} }}
variant="outlined" variant="outlined"
onMouseLeave={(e) => clearAvailabilityTimeSelection()}
> >
<Box <Box
sx={{ width: "100%" }} sx={{ width: "100%" }}
@ -281,6 +267,7 @@ export default function AvailabilityPicker(props: {
} }
</Box> </Box>
<Divider></Divider> <Divider></Divider>
{hours} {hours}
</Card> </Card>
</Stack> </Stack>
@ -288,21 +275,51 @@ export default function AvailabilityPicker(props: {
} }
return ( return (
<Box
sx={{
position: "relative",
width: "100%",
height: "auto",
overflow: "hidden"
}}
>
<Stack <Stack
id="availability-picker"
direction="row" direction="row"
spacing={1} spacing={1}
justifyContent={"safe center"} justifyContent={"safe center"}
sx={{ sx={{
width: "100%", width: "100%",
height: "auto", height: "500px",
maxHeight: "500px",
overflowY: "scroll", overflowY: "scroll",
overflowX: "scroll" overflowX: "scroll"
}} }}
onScroll={(e) => setGhostPreviewProps(null)}
> >
{ {
days.map(a => generateDay(a)) days.map(a => generateDay(a))
} }
</Stack> </Stack>
{
(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>
}
</Box>
); );
} }

View file

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