mirror of
https://github.com/mvvasilev/findtheti.me.git
synced 2025-04-19 13:39:52 +03:00
New availability picker
This commit is contained in:
parent
0dcf39b48a
commit
a2bb7fab1c
2 changed files with 202 additions and 139 deletions
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function createAvailability(day: AvailabilityDay, time: Dayjs) {
|
||||||
setAvailabilityDayBeingSelectedFor(day);
|
let fromTime = time;
|
||||||
setAvailabilityTime({
|
let toTime = time.add(props.availabilityDurationInMinutes, "minutes");
|
||||||
fromTime: startTime,
|
|
||||||
toTime: startTime
|
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])
|
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 (
|
||||||
<Stack
|
<Box
|
||||||
direction="row"
|
|
||||||
spacing={1}
|
|
||||||
justifyContent={"safe center"}
|
|
||||||
sx={{
|
sx={{
|
||||||
|
position: "relative",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "auto",
|
height: "auto",
|
||||||
maxHeight: "500px",
|
overflow: "hidden"
|
||||||
overflowY: "scroll",
|
|
||||||
overflowX: "scroll"
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
46
frontend/src/components/css/AvailabilityPicker.css
Normal file
46
frontend/src/components/css/AvailabilityPicker.css
Normal 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);
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue