findtheti.me/frontend/src/components/AvailabilityPicker.tsx

325 lines
12 KiB
TypeScript
Raw Normal View History

2024-01-10 00:18:42 +02:00
import { Box, Card, Divider, Stack, Theme, Typography, useTheme } from "@mui/material";
2024-01-09 17:23:40 +02:00
import dayjs, { Dayjs } from "dayjs";
2024-01-10 00:18:42 +02:00
import { MouseEvent, ReactNode, useEffect, useState } from "react";
2024-01-09 17:23:40 +02:00
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";
2024-01-10 00:18:42 +02:00
import "./css/AvailabilityPicker.css";
// import { alpha } from '@material-ui/core/styles/colorManipulator';
2024-01-09 17:23:40 +02:00
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(localizedFormat)
type AvailabilityTime = {
fromTime: Dayjs,
toTime: Dayjs
}
type AvailabilityDay = {
forDate: Dayjs,
availableTimes: AvailabilityTime[]
}
2024-01-10 00:18:42 +02:00
type GhostPreviewProps = {
top: number,
left: number,
width: String,
height: String
}
2024-01-09 17:23:40 +02:00
const HALFHOUR_DISPLAY_HEIGHT: number = 15;
const DAY_DISPLAY_WIDTH: String = "150px";
export default function AvailabilityPicker(props: {
fromDate: Dayjs,
toDate: Dayjs,
eventType: String,
availabilityDurationInMinutes: number
}) {
2024-01-10 00:18:42 +02:00
const theme: Theme = useTheme();
2024-01-09 17:23:40 +02:00
const [days, setDays] = useState<AvailabilityDay[]>([]);
2024-01-10 00:18:42 +02:00
const [ghostPreviewProps, setGhostPreviewProps] = useState<GhostPreviewProps | null>();
2024-01-09 17:23:40 +02:00
useEffect(() => {
let localTimezone = dayjs.tz.guess();
let localFromDate = props.fromDate.tz(localTimezone);
let localToDate = props.toDate.tz(localTimezone);
switch (props.eventType) {
case EventTypes.SPECIFIC_DATE: {
createAvailabilitiesBasedOnInitialDate(localFromDate, 1);
break;
}
case EventTypes.DATE_RANGE: {
createAvailabilitiesBasedOnInitialDate(localFromDate, Math.abs(localFromDate.diff(localToDate, "day", false)));
break;
}
case EventTypes.DAY: {
createAvailabilitiesBasedOnUnspecifiedInitialDate(1, localTimezone);
break;
}
case EventTypes.WEEK: {
createAvailabilitiesBasedOnUnspecifiedInitialDate(7, localTimezone);
break;
}
}
}, [props]);
function createAvailabilitiesBasedOnUnspecifiedInitialDate(numberOfDays: number, tz: string) {
createAvailabilitiesBasedOnInitialDate(dayjs.tz("1970-01-05 00:00:00", tz), numberOfDays);
}
function createAvailabilitiesBasedOnInitialDate(date: Dayjs, numberOfDays: number) {
let availabilities: AvailabilityDay[] = [];
for (var i: number = 0; i < numberOfDays; i++) {
let availability: AvailabilityDay = {
forDate: date.add(i, "day").startOf("day"),
availableTimes: []
}
availabilities.push(availability);
}
setDays(availabilities);
}
2024-01-10 00:18:42 +02:00
function displayGhostPeriod(e: React.MouseEvent<HTMLDivElement, MouseEvent>, time: Dayjs) {
let timeInMinutes = (time.hour() * 60) + time.minute();
let timeLeftInDayLessThanDuration = (timeInMinutes + props.availabilityDurationInMinutes) > 24 * 60;
2024-01-09 17:23:40 +02:00
2024-01-10 00:18:42 +02:00
if (timeLeftInDayLessThanDuration) {
2024-01-09 17:23:40 +02:00
return;
}
2024-01-10 00:18:42 +02:00
let scrollTop = document.getElementById('availability-picker')?.scrollTop ?? 0;
let scrollLeft = document.getElementById('availability-picker')?.scrollLeft ?? 0;
const element = e.target.getBoundingClientRect();
2024-01-09 17:23:40 +02:00
2024-01-10 00:18:42 +02:00
setGhostPreviewProps({
top: e.target?.offsetTop - scrollTop,
left: e.target?.offsetLeft - scrollLeft,
width: element.width,
height: `${(props.availabilityDurationInMinutes/60) * 2 * HALFHOUR_DISPLAY_HEIGHT}px`
})
2024-01-09 17:23:40 +02:00
}
2024-01-10 00:18:42 +02:00
function createAvailability(day: AvailabilityDay, time: Dayjs) {
let fromTime = time;
let toTime = time.add(props.availabilityDurationInMinutes, "minutes");
2024-01-09 17:23:40 +02:00
2024-01-10 00:18:42 +02:00
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)));
2024-01-09 17:23:40 +02:00
2024-01-10 00:18:42 +02:00
// the newly created availability crosses another single one. Both have the same from and to. Do nothing.
if (existingTimeContainingFrom >= 0 && existingTimeContainingTo >= 0 && existingTimeContainingFrom === existingTimeContainingTo) {
2024-01-09 17:23:40 +02:00
return;
}
2024-01-10 00:18:42 +02:00
// 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);
2024-01-09 17:23:40 +02:00
2024-01-10 00:18:42 +02:00
day.availableTimes.push({
fromTime: newFrom,
toTime: newTo
});
2024-01-09 17:23:40 +02:00
return;
}
2024-01-10 00:18:42 +02:00
// 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
});
2024-01-09 17:23:40 +02:00
return;
}
2024-01-10 00:18:42 +02:00
// 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;
2024-01-09 17:23:40 +02:00
2024-01-10 00:18:42 +02:00
day.availableTimes.splice(existingTimeContainingFrom);
2024-01-09 17:23:40 +02:00
2024-01-10 00:18:42 +02:00
day.availableTimes.push({
fromTime: fromTime,
toTime: newTo
});
2024-01-09 17:23:40 +02:00
2024-01-10 00:18:42 +02:00
return;
}
day.availableTimes.push({
fromTime: fromTime,
toTime: toTime
});
2024-01-09 17:23:40 +02:00
2024-01-10 00:18:42 +02:00
setDays([...days])
2024-01-09 17:23:40 +02:00
}
2024-01-10 00:18:42 +02:00
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)));
2024-01-09 17:23:40 +02:00
}
function generateDay(day: AvailabilityDay) {
const HOUR_TEXT_COLOR: String = "#ddd";
2024-01-10 00:18:42 +02:00
let hours = [];
for (var i = 0; i < 24; i++) {
2024-01-09 17:23:40 +02:00
let time = day.forDate.set("hour", i).set("minute", 0).set("second", 0);
2024-01-10 00:18:42 +02:00
hours.push(
2024-01-09 17:23:40 +02:00
<Box
key={`${i}`}
2024-01-10 00:18:42 +02:00
className={(i % 2 == 0) ? "hour-light" : "hour-dark"}
2024-01-09 17:23:40 +02:00
>
<Box
2024-01-10 00:18:42 +02:00
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)}
2024-01-09 17:23:40 +02:00
>
<Typography
className={"noselect"}
textAlign={"left"}
fontSize={"0.65em"}
color={HOUR_TEXT_COLOR}
>
{ utils.formatTimeFromHourOfDay(i, 0) }
</Typography>
</Box>
<Box
2024-01-10 00:18:42 +02:00
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"))}
2024-01-09 17:23:40 +02:00
>
</Box>
</Box>
);
2024-01-10 00:18:42 +02:00
}
2024-01-09 17:23:40 +02:00
return (
<Stack
key={day.forDate.format()}
direction="column"
sx={{
minWidth: DAY_DISPLAY_WIDTH,
width: DAY_DISPLAY_WIDTH
}}
overflow={"visible"}
>
<Card
sx={{
width: "100%",
height: "fit-content",
overflow: "visible"
}}
variant="outlined"
>
<Box
sx={{ width: "100%" }}
padding={1}
>
{
(props.eventType === EventTypes.WEEK) &&
<Typography>
{ day.forDate.format("dddd") }
</Typography>
}
{
(props.eventType === EventTypes.DAY) &&
<Typography>
Any Day
</Typography>
}
{
(props.eventType === EventTypes.DATE_RANGE || props.eventType === EventTypes.SPECIFIC_DATE) &&
<Typography>
{ day.forDate.format("LL") }
</Typography>
}
</Box>
<Divider></Divider>
2024-01-10 00:18:42 +02:00
2024-01-09 17:23:40 +02:00
{hours}
</Card>
</Stack>
);
}
return (
2024-01-10 00:18:42 +02:00
<Box
2024-01-09 17:23:40 +02:00
sx={{
2024-01-10 00:18:42 +02:00
position: "relative",
2024-01-09 17:23:40 +02:00
width: "100%",
2024-01-10 00:18:42 +02:00
height: "auto",
overflow: "hidden"
}}
2024-01-09 17:23:40 +02:00
>
2024-01-10 00:18:42 +02:00
<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>
2024-01-09 17:23:40 +02:00
{
2024-01-10 00:18:42 +02:00
(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>
2024-01-09 17:23:40 +02:00
}
2024-01-10 00:18:42 +02:00
</Box>
2024-01-09 17:23:40 +02:00
);
}