Working version ready for testing
* More tests to be added to verify slots logic * Adds Jest * Implements logic to the booking code to take into account grayed days * Slots take workhours into account TODO: Improve the tests, evaluate the structure, small re-orgs here and there for improved readability / better code
This commit is contained in:
parent
1dce84fa8f
commit
ef3274d8f3
14 changed files with 2992 additions and 872 deletions
|
@ -18,12 +18,14 @@
|
||||||
"rules": {
|
"rules": {
|
||||||
"prettier/prettier": ["error"],
|
"prettier/prettier": ["error"],
|
||||||
"@typescript-eslint/no-unused-vars": "error",
|
"@typescript-eslint/no-unused-vars": "error",
|
||||||
"react/react-in-jsx-scope": "off"
|
"react/react-in-jsx-scope": "off",
|
||||||
|
"react/prop-types": "off"
|
||||||
},
|
},
|
||||||
"env": {
|
"env": {
|
||||||
"browser": true,
|
"browser": true,
|
||||||
"node": true,
|
"node": true,
|
||||||
"es6": true
|
"es6": true,
|
||||||
|
"jest": true
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"react": {
|
"react": {
|
||||||
|
|
|
@ -1,87 +1,35 @@
|
||||||
import dayjs, {Dayjs} from "dayjs";
|
|
||||||
import isBetween from 'dayjs/plugin/isBetween';
|
|
||||||
dayjs.extend(isBetween);
|
|
||||||
import {useEffect, useMemo, useState} from "react";
|
|
||||||
import getSlots from "../../lib/slots";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {timeZone} from "../../lib/clock";
|
import { useRouter } from "next/router";
|
||||||
import {useRouter} from "next/router";
|
import Slots from "./Slots";
|
||||||
|
|
||||||
const AvailableTimes = (props) => {
|
|
||||||
|
|
||||||
|
const AvailableTimes = ({ date, eventLength, eventTypeId, workingHours, timeFormat }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, rescheduleUid } = router.query;
|
const { user, rescheduleUid } = router.query;
|
||||||
const [loaded, setLoaded] = useState(false);
|
const { slots } = Slots({ date, eventLength, workingHours });
|
||||||
|
|
||||||
const times = getSlots({
|
|
||||||
calendarTimeZone: props.user.timeZone,
|
|
||||||
selectedTimeZone: timeZone(),
|
|
||||||
eventLength: props.eventType.length,
|
|
||||||
selectedDate: props.date,
|
|
||||||
dayStartTime: props.user.startTime,
|
|
||||||
dayEndTime: props.user.endTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleAvailableSlots = (busyTimes: []) => {
|
|
||||||
// Check for conflicts
|
|
||||||
for (let i = times.length - 1; i >= 0; i -= 1) {
|
|
||||||
busyTimes.forEach(busyTime => {
|
|
||||||
let startTime = dayjs(busyTime.start);
|
|
||||||
let endTime = dayjs(busyTime.end);
|
|
||||||
|
|
||||||
// Check if start times are the same
|
|
||||||
if (dayjs(times[i]).format('HH:mm') == startTime.format('HH:mm')) {
|
|
||||||
times.splice(i, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if time is between start and end times
|
|
||||||
if (dayjs(times[i]).isBetween(startTime, endTime)) {
|
|
||||||
times.splice(i, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if slot end time is between start and end time
|
|
||||||
if (dayjs(times[i]).add(props.eventType.length, 'minutes').isBetween(startTime, endTime)) {
|
|
||||||
times.splice(i, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if startTime is between slot
|
|
||||||
if (startTime.isBetween(dayjs(times[i]), dayjs(times[i]).add(props.eventType.length, 'minutes'))) {
|
|
||||||
times.splice(i, 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Display available times
|
|
||||||
setLoaded(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Re-render only when invitee changes date
|
|
||||||
useEffect(() => {
|
|
||||||
setLoaded(false);
|
|
||||||
fetch(`/api/availability/${user}?dateFrom=${props.date.startOf('day').utc().format()}&dateTo=${props.date.endOf('day').utc().format()}`)
|
|
||||||
.then( res => res.json())
|
|
||||||
.then(handleAvailableSlots);
|
|
||||||
}, [props.date]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:max-h-97 overflow-y-auto">
|
<div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:max-h-97 overflow-y-auto">
|
||||||
<div className="text-gray-600 font-light text-xl mb-4 text-left">
|
<div className="text-gray-600 font-light text-xl mb-4 text-left">
|
||||||
<span className="w-1/2">
|
<span className="w-1/2">{date.format("dddd DD MMMM YYYY")}</span>
|
||||||
{props.date.format("dddd DD MMMM YYYY")}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{
|
{slots.length > 0 ? (
|
||||||
loaded ? times.map((time) =>
|
slots.map((slot) => (
|
||||||
<div key={dayjs(time).utc().format()}>
|
<div key={slot.format()}>
|
||||||
<Link
|
<Link
|
||||||
href={`/${props.user.username}/book?date=${dayjs(time).utc().format()}&type=${props.eventType.id}` + (rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "")}>
|
href={
|
||||||
<a key={dayjs(time).format("hh:mma")}
|
`/${user}/book?date=${slot.utc().format()}&type=${eventTypeId}` +
|
||||||
className="block font-medium mb-4 text-blue-600 border border-blue-600 rounded hover:text-white hover:bg-blue-600 py-4">{dayjs(time).tz(timeZone()).format(props.timeFormat)}</a>
|
(rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "")
|
||||||
|
}>
|
||||||
|
<a className="block font-medium mb-4 text-blue-600 border border-blue-600 rounded hover:text-white hover:bg-blue-600 py-4">
|
||||||
|
{slot.format(timeFormat)}
|
||||||
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : <div className="loader"></div>
|
))
|
||||||
}
|
) : (
|
||||||
|
<div className="loader" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default AvailableTimes;
|
export default AvailableTimes;
|
||||||
|
|
|
@ -1,90 +1,90 @@
|
||||||
import dayjs from "dayjs";
|
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
|
||||||
import {ChevronLeftIcon, ChevronRightIcon} from "@heroicons/react/solid";
|
import { useEffect, useState } from "react";
|
||||||
import {useEffect, useState} from "react";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
|
import isToday from "dayjs/plugin/isToday";
|
||||||
const DatePicker = ({ weekStart, onDatePicked }) => {
|
dayjs.extend(isToday);
|
||||||
|
|
||||||
|
const DatePicker = ({ weekStart, onDatePicked, workingHours, disableToday }) => {
|
||||||
|
const workingDays = workingHours.reduce((workingDays: number[], wh) => [...workingDays, ...wh.days], []);
|
||||||
const [selectedMonth, setSelectedMonth] = useState(dayjs().month());
|
const [selectedMonth, setSelectedMonth] = useState(dayjs().month());
|
||||||
const [selectedDay, setSelectedDay] = useState(dayjs().date());
|
const [selectedDate, setSelectedDate] = useState();
|
||||||
const [hasPickedDate, setHasPickedDate] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasPickedDate) {
|
if (selectedDate) onDatePicked(selectedDate);
|
||||||
onDatePicked(dayjs().month(selectedMonth).date(selectedDay));
|
}, [selectedDate, onDatePicked]);
|
||||||
}
|
|
||||||
}, [hasPickedDate, selectedDay]);
|
|
||||||
|
|
||||||
// Handle month changes
|
// Handle month changes
|
||||||
const incrementMonth = () => {
|
const incrementMonth = () => {
|
||||||
setSelectedMonth(selectedMonth + 1);
|
setSelectedMonth(selectedMonth + 1);
|
||||||
}
|
};
|
||||||
|
|
||||||
const decrementMonth = () => {
|
const decrementMonth = () => {
|
||||||
setSelectedMonth(selectedMonth - 1);
|
setSelectedMonth(selectedMonth - 1);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Set up calendar
|
// Set up calendar
|
||||||
var daysInMonth = dayjs().month(selectedMonth).daysInMonth();
|
const daysInMonth = dayjs().month(selectedMonth).daysInMonth();
|
||||||
var days = [];
|
const days = [];
|
||||||
for (let i = 1; i <= daysInMonth; i++) {
|
for (let i = 1; i <= daysInMonth; i++) {
|
||||||
days.push(i);
|
days.push(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create placeholder elements for empty days in first week
|
// Create placeholder elements for empty days in first week
|
||||||
let weekdayOfFirst = dayjs().month(selectedMonth).date(1).day();
|
let weekdayOfFirst = dayjs().month(selectedMonth).date(1).day();
|
||||||
if (weekStart === 'Monday') {
|
if (weekStart === "Monday") {
|
||||||
weekdayOfFirst -= 1;
|
weekdayOfFirst -= 1;
|
||||||
if (weekdayOfFirst < 0)
|
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
|
||||||
weekdayOfFirst = 6;
|
|
||||||
}
|
}
|
||||||
const emptyDays = Array(weekdayOfFirst).fill(null).map((day, i) =>
|
const emptyDays = Array(weekdayOfFirst)
|
||||||
<div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}>
|
.fill(null)
|
||||||
{null}
|
.map((day, i) => (
|
||||||
</div>
|
<div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}>
|
||||||
);
|
{null}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
const isDisabled = (day: number) => {
|
||||||
|
const date: Dayjs = dayjs().month(selectedMonth).date(day);
|
||||||
|
return (
|
||||||
|
date.isBefore(dayjs()) || !workingDays.includes(+date.format("d")) || (date.isToday() && disableToday)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Combine placeholder days with actual days
|
// Combine placeholder days with actual days
|
||||||
const calendar = [...emptyDays, ...days.map((day) =>
|
const calendar = [
|
||||||
<button key={day}
|
...emptyDays,
|
||||||
onClick={() => { setHasPickedDate(true); setSelectedDay(day) }}
|
...days.map((day) => (
|
||||||
disabled={
|
<button
|
||||||
selectedMonth < parseInt(dayjs().format('MM')) && dayjs().month(selectedMonth).format("D") > day
|
key={day}
|
||||||
}
|
onClick={() => setSelectedDate(dayjs().month(selectedMonth).date(day))}
|
||||||
className={
|
disabled={
|
||||||
"text-center w-10 h-10 rounded-full mx-auto " + (
|
(selectedMonth < parseInt(dayjs().format("MM")) &&
|
||||||
dayjs().isSameOrBefore(dayjs().date(day).month(selectedMonth)
|
dayjs().month(selectedMonth).format("D") > day) ||
|
||||||
) ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-400 font-light'
|
isDisabled(day)
|
||||||
) + (
|
}
|
||||||
dayjs().date(selectedDay).month(selectedMonth).format("D") == day ? ' bg-blue-600 text-white-important' : ''
|
className={
|
||||||
)
|
"text-center w-10 h-10 rounded-full mx-auto" +
|
||||||
}>
|
(isDisabled(day) ? " text-gray-400 font-light" : " text-blue-600 font-medium") +
|
||||||
{day}
|
(selectedDate && selectedDate.isSame(dayjs().month(selectedMonth).date(day), "day")
|
||||||
</button>
|
? " bg-blue-600 text-white-important"
|
||||||
)];
|
: !isDisabled(day)
|
||||||
|
? " bg-blue-50"
|
||||||
|
: "")
|
||||||
|
}>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={"mt-8 sm:mt-0 " + (selectedDate ? "sm:w-1/3 border-r sm:px-4" : "sm:w-1/2 sm:pl-4")}>
|
||||||
className={
|
|
||||||
"mt-8 sm:mt-0 " +
|
|
||||||
(hasPickedDate
|
|
||||||
? "sm:w-1/3 border-r sm:px-4"
|
|
||||||
: "sm:w-1/2 sm:pl-4")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex text-gray-600 font-light text-xl mb-4 ml-2">
|
<div className="flex text-gray-600 font-light text-xl mb-4 ml-2">
|
||||||
<span className="w-1/2">
|
<span className="w-1/2">{dayjs().month(selectedMonth).format("MMMM YYYY")}</span>
|
||||||
{dayjs().month(selectedMonth).format("MMMM YYYY")}
|
|
||||||
</span>
|
|
||||||
<div className="w-1/2 text-right">
|
<div className="w-1/2 text-right">
|
||||||
<button
|
<button
|
||||||
onClick={decrementMonth}
|
onClick={decrementMonth}
|
||||||
className={
|
className={"mr-4 " + (selectedMonth < parseInt(dayjs().format("MM")) && "text-gray-400")}
|
||||||
"mr-4 " +
|
disabled={selectedMonth < parseInt(dayjs().format("MM"))}>
|
||||||
(selectedMonth < parseInt(dayjs().format("MM")) &&
|
|
||||||
"text-gray-400")
|
|
||||||
}
|
|
||||||
disabled={selectedMonth < parseInt(dayjs().format("MM"))}
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon className="w-5 h-5" />
|
<ChevronLeftIcon className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={incrementMonth}>
|
<button onClick={incrementMonth}>
|
||||||
|
@ -93,17 +93,17 @@ const DatePicker = ({ weekStart, onDatePicked }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-7 gap-y-4 text-center">
|
<div className="grid grid-cols-7 gap-y-4 text-center">
|
||||||
{
|
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||||
['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
.sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0))
|
||||||
.sort( (a, b) => weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0 )
|
.map((weekDay) => (
|
||||||
.map( (weekDay) =>
|
<div key={weekDay} className="uppercase text-gray-400 text-xs tracking-widest">
|
||||||
<div key={weekDay} className="uppercase text-gray-400 text-xs tracking-widest">{weekDay}</div>
|
{weekDay}
|
||||||
)
|
</div>
|
||||||
}
|
))}
|
||||||
{calendar}
|
{calendar}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default DatePicker;
|
export default DatePicker;
|
||||||
|
|
66
components/booking/Slots.tsx
Normal file
66
components/booking/Slots.tsx
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import getSlots from "../../lib/slots";
|
||||||
|
|
||||||
|
const Slots = (props) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { user } = router.query;
|
||||||
|
const [slots, setSlots] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSlots([]);
|
||||||
|
fetch(
|
||||||
|
`/api/availability/${user}?dateFrom=${props.date.startOf("day").utc().format()}&dateTo=${props.date
|
||||||
|
.endOf("day")
|
||||||
|
.utc()
|
||||||
|
.format()}`
|
||||||
|
)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then(handleAvailableSlots);
|
||||||
|
}, [props.date]);
|
||||||
|
|
||||||
|
const handleAvailableSlots = (busyTimes: []) => {
|
||||||
|
const times = getSlots({
|
||||||
|
frequency: props.eventLength,
|
||||||
|
inviteeDate: props.date,
|
||||||
|
workingHours: props.workingHours,
|
||||||
|
minimumBookingNotice: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for conflicts
|
||||||
|
for (let i = times.length - 1; i >= 0; i -= 1) {
|
||||||
|
busyTimes.forEach((busyTime) => {
|
||||||
|
const startTime = dayjs(busyTime.start);
|
||||||
|
const endTime = dayjs(busyTime.end);
|
||||||
|
|
||||||
|
// Check if start times are the same
|
||||||
|
if (dayjs(times[i]).format("HH:mm") == startTime.format("HH:mm")) {
|
||||||
|
times.splice(i, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if time is between start and end times
|
||||||
|
if (dayjs(times[i]).isBetween(startTime, endTime)) {
|
||||||
|
times.splice(i, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if slot end time is between start and end time
|
||||||
|
if (dayjs(times[i]).add(props.eventType.length, "minutes").isBetween(startTime, endTime)) {
|
||||||
|
times.splice(i, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if startTime is between slot
|
||||||
|
if (startTime.isBetween(dayjs(times[i]), dayjs(times[i]).add(props.eventType.length, "minutes"))) {
|
||||||
|
times.splice(i, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Display available times
|
||||||
|
setSlots(times);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
slots,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Slots;
|
|
@ -1,45 +1,48 @@
|
||||||
import React, {useEffect, useState} from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import TimezoneSelect from "react-timezone-select";
|
import TimezoneSelect from "react-timezone-select";
|
||||||
import {PencilAltIcon, TrashIcon} from "@heroicons/react/outline";
|
import { TrashIcon } from "@heroicons/react/outline";
|
||||||
import {WeekdaySelect, Weekday} from "./WeekdaySelect";
|
import { WeekdaySelect } from "./WeekdaySelect";
|
||||||
import SetTimesModal from "./modal/SetTimesModal";
|
import SetTimesModal from "./modal/SetTimesModal";
|
||||||
import Schedule from '../../lib/schedule.model';
|
import Schedule from "../../lib/schedule.model";
|
||||||
import dayjs, {Dayjs} from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import utc from 'dayjs/plugin/utc';
|
import utc from "dayjs/plugin/utc";
|
||||||
import timezone from 'dayjs/plugin/timezone';
|
import timezone from "dayjs/plugin/timezone";
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
export const Scheduler = (props) => {
|
export const Scheduler = (props) => {
|
||||||
|
const [schedules, setSchedules]: Schedule[] = useState(
|
||||||
const [ schedules, setSchedules ]: Schedule[] = useState(props.schedules.map( schedule => {
|
props.schedules.map((schedule) => {
|
||||||
const startDate = schedule.isOverride ? dayjs(schedule.startDate) : dayjs.utc().startOf('day').add(schedule.startTime, 'minutes')
|
const startDate = schedule.isOverride
|
||||||
return (
|
? dayjs(schedule.startDate)
|
||||||
{
|
: dayjs.utc().startOf("day").add(schedule.startTime, "minutes").tz(props.timeZone);
|
||||||
|
return {
|
||||||
days: schedule.days,
|
days: schedule.days,
|
||||||
startDate,
|
startDate,
|
||||||
endDate: startDate.add(schedule.length, 'minutes')
|
endDate: startDate.add(schedule.length, "minutes"),
|
||||||
}
|
};
|
||||||
)
|
})
|
||||||
}));
|
);
|
||||||
|
|
||||||
const [ timeZone, setTimeZone ] = useState(props.timeZone);
|
const [timeZone, setTimeZone] = useState(props.timeZone);
|
||||||
const [ editSchedule, setEditSchedule ] = useState(-1);
|
const [editSchedule, setEditSchedule] = useState(-1);
|
||||||
|
|
||||||
useEffect( () => {
|
useEffect(() => {
|
||||||
props.onChange(schedules);
|
props.onChange(schedules);
|
||||||
}, [schedules])
|
}, [schedules]);
|
||||||
|
|
||||||
const addNewSchedule = () => setEditSchedule(schedules.length);
|
const addNewSchedule = () => setEditSchedule(schedules.length);
|
||||||
|
|
||||||
const applyEditSchedule = (changed: Schedule) => {
|
const applyEditSchedule = (changed: Schedule) => {
|
||||||
const replaceWith = {
|
const replaceWith = {
|
||||||
...schedules[editSchedule],
|
...schedules[editSchedule],
|
||||||
...changed
|
...changed,
|
||||||
};
|
};
|
||||||
|
|
||||||
schedules.splice(editSchedule, 1, replaceWith);
|
schedules.splice(editSchedule, 1, replaceWith);
|
||||||
|
|
||||||
setSchedules([].concat(schedules));
|
setSchedules([].concat(schedules));
|
||||||
}
|
};
|
||||||
|
|
||||||
const removeScheduleAt = (toRemove: number) => {
|
const removeScheduleAt = (toRemove: number) => {
|
||||||
schedules.splice(toRemove, 1);
|
schedules.splice(toRemove, 1);
|
||||||
|
@ -49,7 +52,7 @@ export const Scheduler = (props) => {
|
||||||
const setWeekdays = (idx: number, days: number[]) => {
|
const setWeekdays = (idx: number, days: number[]) => {
|
||||||
schedules[idx].days = days;
|
schedules[idx].days = days;
|
||||||
setSchedules([].concat(schedules));
|
setSchedules([].concat(schedules));
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
@ -60,26 +63,40 @@ export const Scheduler = (props) => {
|
||||||
Timezone
|
Timezone
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<TimezoneSelect id="timeZone" value={timeZone} onChange={setTimeZone} className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md" />
|
<TimezoneSelect
|
||||||
|
id="timeZone"
|
||||||
|
value={timeZone}
|
||||||
|
onChange={setTimeZone}
|
||||||
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
{schedules.map( (schedule, idx) =>
|
{schedules.map((schedule, idx) => (
|
||||||
<li key={idx} className="py-2 flex justify-between border-t">
|
<li key={idx} className="py-2 flex justify-between border-t">
|
||||||
<div className="inline-flex ml-2">
|
<div className="inline-flex ml-2">
|
||||||
<WeekdaySelect defaultValue={schedules[idx].days} onSelect={(days: number[]) => setWeekdays(idx, days)} />
|
<WeekdaySelect
|
||||||
<button className="ml-2 text-sm px-2" type="button" onClick={() => setEditSchedule(idx)}>
|
defaultValue={schedules[idx].days}
|
||||||
{schedule.startDate.format(schedule.startDate.minute() === 0 ? 'ha' : 'h:mma')} until {schedule.endDate.format(schedule.endDate.minute() === 0 ? 'ha' : 'h:mma')}
|
onSelect={(days: number[]) => setWeekdays(idx, days)}
|
||||||
|
/>
|
||||||
|
<button className="ml-2 text-sm px-2" type="button" onClick={() => setEditSchedule(idx)}>
|
||||||
|
{dayjs(schedule.startDate).format(schedule.startDate.minute() === 0 ? "ha" : "h:mma")}{" "}
|
||||||
|
until {dayjs(schedule.endDate).format(schedule.endDate.minute() === 0 ? "ha" : "h:mma")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeScheduleAt(idx)}
|
||||||
|
className="btn-sm bg-transparent px-2 py-1 ml-1">
|
||||||
|
<TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</li>
|
||||||
<button type="button" onClick={() => removeScheduleAt(idx)}
|
))}
|
||||||
className="btn-sm bg-transparent px-2 py-1 ml-1">
|
|
||||||
<TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" />
|
|
||||||
</button>
|
|
||||||
</li>)}
|
|
||||||
</ul>
|
</ul>
|
||||||
<hr />
|
<hr />
|
||||||
<button type="button" onClick={addNewSchedule} className="btn-white btn-sm m-2">Add another</button>
|
<button type="button" onClick={addNewSchedule} className="btn-white btn-sm m-2">
|
||||||
|
Add another
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-l p-2 w-2/5 text-sm bg-gray-50">
|
<div className="border-l p-2 w-2/5 text-sm bg-gray-50">
|
||||||
{/*<p className="font-bold mb-2">Add date overrides</p>
|
{/*<p className="font-bold mb-2">Add date overrides</p>
|
||||||
|
@ -89,14 +106,16 @@ export const Scheduler = (props) => {
|
||||||
<button className="btn-sm btn-white">Add a date override</button>*/}
|
<button className="btn-sm btn-white">Add a date override</button>*/}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{editSchedule >= 0 &&
|
{editSchedule >= 0 && (
|
||||||
<SetTimesModal schedule={schedules[editSchedule]}
|
<SetTimesModal
|
||||||
onChange={applyEditSchedule}
|
schedule={schedules[editSchedule]}
|
||||||
onExit={() => setEditSchedule(-1)} />
|
onChange={applyEditSchedule}
|
||||||
}
|
onExit={() => setEditSchedule(-1)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/*{showDateOverrideModal &&
|
{/*{showDateOverrideModal &&
|
||||||
<DateOverrideModal />
|
<DateOverrideModal />
|
||||||
}*/}
|
}*/}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
|
@ -1,42 +1,53 @@
|
||||||
import React, {useEffect, useState} from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
export const WeekdaySelect = (props) => {
|
export const WeekdaySelect = (props) => {
|
||||||
|
const [activeDays, setActiveDays] = useState(
|
||||||
|
[...Array(7).keys()].map((v, i) => (props.defaultValue || []).includes(i))
|
||||||
|
);
|
||||||
|
|
||||||
const [ activeDays, setActiveDays ] = useState([1,2,3,4,5,6,7].map( (v) => (props.defaultValue || []).indexOf(v) !== -1));
|
const days = ["S", "M", "T", "W", "T", "F", "S"];
|
||||||
const days = [ 'S', 'M', 'T', 'W', 'T', 'F', 'S' ];
|
|
||||||
|
|
||||||
useEffect( () => {
|
useEffect(() => {
|
||||||
props.onSelect(activeDays.map( (isActive, idx) => isActive ? idx + 1 : 0).filter( (v) => 0 !== v ));
|
props.onSelect(activeDays.map((v, idx) => (v ? idx : -1)).filter((v) => v !== -1));
|
||||||
}, [activeDays]);
|
}, [activeDays]);
|
||||||
|
|
||||||
const toggleDay = (e, idx: number) => {
|
const toggleDay = (e, idx: number) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
activeDays[idx] = !activeDays[idx];
|
activeDays[idx] = !activeDays[idx];
|
||||||
setActiveDays([].concat(activeDays));
|
setActiveDays([].concat(activeDays));
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="weekdaySelect">
|
<div className="weekdaySelect">
|
||||||
<div className="inline-flex">
|
<div className="inline-flex">
|
||||||
{days.map( (day, idx) => activeDays[idx] ?
|
{days.map((day, idx) =>
|
||||||
<button key={idx} onClick={(e) => toggleDay(e, idx)}
|
activeDays[idx] ? (
|
||||||
style={ {"marginLeft": "-2px"} }
|
<button
|
||||||
className={`
|
key={idx}
|
||||||
|
onClick={(e) => toggleDay(e, idx)}
|
||||||
|
style={{ marginLeft: "-2px" }}
|
||||||
|
className={`
|
||||||
active focus:outline-none border-2 border-blue-500 px-2 py-1 rounded
|
active focus:outline-none border-2 border-blue-500 px-2 py-1 rounded
|
||||||
${activeDays[idx+1] ? 'rounded-r-none': ''}
|
${activeDays[idx + 1] ? "rounded-r-none" : ""}
|
||||||
${activeDays[idx-1] ? 'rounded-l-none': ''}
|
${activeDays[idx - 1] ? "rounded-l-none" : ""}
|
||||||
${idx === 0 ? 'rounded-l' : ''}
|
${idx === 0 ? "rounded-l" : ""}
|
||||||
${idx === days.length-1 ? 'rounded-r' : ''}
|
${idx === days.length - 1 ? "rounded-r" : ""}
|
||||||
`}>
|
`}>
|
||||||
{day}
|
{day}
|
||||||
</button>
|
</button>
|
||||||
:
|
) : (
|
||||||
<button key={idx} onClick={(e) => toggleDay(e, idx)}
|
<button
|
||||||
style={ {"marginTop": "1px", "marginBottom": "1px"} }
|
key={idx}
|
||||||
className={`border focus:outline-none px-2 py-1 rounded-none ${idx === 0 ? 'rounded-l' : 'border-l-0'} ${idx === days.length-1 ? 'rounded-r' : ''}`}>
|
onClick={(e) => toggleDay(e, idx)}
|
||||||
{day}
|
style={{ marginTop: "1px", marginBottom: "1px" }}
|
||||||
</button>
|
className={`border focus:outline-none px-2 py-1 rounded-none ${
|
||||||
|
idx === 0 ? "rounded-l" : "border-l-0"
|
||||||
|
} ${idx === days.length - 1 ? "rounded-r" : ""}`}>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>);
|
</div>
|
||||||
}
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -1,14 +1,20 @@
|
||||||
import {ClockIcon} from "@heroicons/react/outline";
|
import { ClockIcon } from "@heroicons/react/outline";
|
||||||
import {useRef} from "react";
|
import { useRef } from "react";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import timezone from "dayjs/plugin/utc";
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
export default function SetTimesModal(props) {
|
export default function SetTimesModal(props) {
|
||||||
|
const { startDate, endDate } = props.schedule || {
|
||||||
const {startDate, endDate} = props.schedule || {
|
startDate: dayjs.utc().startOf("day").add(540, "minutes"),
|
||||||
startDate: dayjs().startOf('day').add(540, 'minutes'),
|
endDate: dayjs.utc().startOf("day").add(1020, "minutes"),
|
||||||
endDate: dayjs().startOf('day').add(1020, 'minutes'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
startDate.tz(props.timeZone);
|
||||||
|
endDate.tz(props.timeZone);
|
||||||
|
|
||||||
const startHoursRef = useRef<HTMLInputElement>();
|
const startHoursRef = useRef<HTMLInputElement>();
|
||||||
const startMinsRef = useRef<HTMLInputElement>();
|
const startMinsRef = useRef<HTMLInputElement>();
|
||||||
const endHoursRef = useRef<HTMLInputElement>();
|
const endHoursRef = useRef<HTMLInputElement>();
|
||||||
|
@ -31,60 +37,108 @@ export default function SetTimesModal(props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
<div
|
||||||
|
className="fixed z-10 inset-0 overflow-y-auto"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true">
|
||||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
|
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
|
||||||
|
|
||||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||||
|
​
|
||||||
|
</span>
|
||||||
|
|
||||||
<div
|
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||||
className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
|
||||||
<div className="sm:flex sm:items-start mb-4">
|
<div className="sm:flex sm:items-start mb-4">
|
||||||
<div
|
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||||
className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
<ClockIcon className="h-6 w-6 text-blue-600" />
|
||||||
<ClockIcon className="h-6 w-6 text-blue-600"/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||||
Change when you are available for bookings
|
Change when you are available for bookings
|
||||||
</h3>
|
</h3>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">Set your work schedule</p>
|
||||||
Set your work schedule
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex mb-4">
|
<div className="flex mb-4">
|
||||||
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">Start time</label>
|
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">Start time</label>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="startHours" className="sr-only">Hours</label>
|
<label htmlFor="startHours" className="sr-only">
|
||||||
<input ref={startHoursRef} type="number" min="0" max="23" maxLength="2" name="hours" id="startHours"
|
Hours
|
||||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
</label>
|
||||||
placeholder="9" defaultValue={startDate.format('H')} />
|
<input
|
||||||
|
ref={startHoursRef}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="23"
|
||||||
|
maxLength="2"
|
||||||
|
name="hours"
|
||||||
|
id="startHours"
|
||||||
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
|
placeholder="9"
|
||||||
|
defaultValue={startDate.format("H")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="mx-2 pt-1">:</span>
|
<span className="mx-2 pt-1">:</span>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="startMinutes" className="sr-only">Minutes</label>
|
<label htmlFor="startMinutes" className="sr-only">
|
||||||
<input ref={startMinsRef} type="number" min="0" max="59" step="15" maxLength="2" name="minutes" id="startMinutes"
|
Minutes
|
||||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
</label>
|
||||||
placeholder="30" defaultValue={startDate.format('m')} />
|
<input
|
||||||
|
ref={startMinsRef}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="59"
|
||||||
|
step="15"
|
||||||
|
maxLength="2"
|
||||||
|
name="minutes"
|
||||||
|
id="startMinutes"
|
||||||
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
|
placeholder="30"
|
||||||
|
defaultValue={startDate.format("m")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">End time</label>
|
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">End time</label>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="endHours" className="sr-only">Hours</label>
|
<label htmlFor="endHours" className="sr-only">
|
||||||
<input ref={endHoursRef} type="number" min="0" max="23" maxLength="2" name="hours" id="endHours"
|
Hours
|
||||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
</label>
|
||||||
placeholder="17" defaultValue={endDate.format('H')} />
|
<input
|
||||||
|
ref={endHoursRef}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="24"
|
||||||
|
maxLength="2"
|
||||||
|
name="hours"
|
||||||
|
id="endHours"
|
||||||
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
|
placeholder="17"
|
||||||
|
defaultValue={endDate.format("H")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="mx-2 pt-1">:</span>
|
<span className="mx-2 pt-1">:</span>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="endMinutes" className="sr-only">Minutes</label>
|
<label htmlFor="endMinutes" className="sr-only">
|
||||||
<input ref={endMinsRef} type="number" min="0" max="59" maxLength="2" step="15" name="minutes" id="endMinutes"
|
Minutes
|
||||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
</label>
|
||||||
placeholder="30" defaultValue={endDate.format('m')} />
|
<input
|
||||||
|
ref={endMinsRef}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="59"
|
||||||
|
maxLength="2"
|
||||||
|
step="15"
|
||||||
|
name="minutes"
|
||||||
|
id="endMinutes"
|
||||||
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
|
placeholder="30"
|
||||||
|
defaultValue={endDate.format("m")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||||
|
@ -97,5 +151,6 @@ export default function SetTimesModal(props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>);
|
</div>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
174
lib/slots.ts
174
lib/slots.ts
|
@ -1,94 +1,108 @@
|
||||||
const dayjs = require("dayjs");
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
const isToday = require("dayjs/plugin/isToday");
|
|
||||||
const utc = require("dayjs/plugin/utc");
|
|
||||||
const timezone = require("dayjs/plugin/timezone");
|
|
||||||
|
|
||||||
dayjs.extend(isToday);
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
|
||||||
|
|
||||||
const getMinutesFromMidnight = (date) => {
|
interface GetSlotsType {
|
||||||
return date.hour() * 60 + date.minute();
|
inviteeDate: Dayjs;
|
||||||
|
frequency: number;
|
||||||
|
workingHours: { [WeekDay]: Boundary[] };
|
||||||
|
minimumBookingNotice?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Boundary {
|
||||||
|
lowerBound: number;
|
||||||
|
upperBound: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const freqApply: number = (cb, value: number, frequency: number): number => cb(value / frequency) * frequency;
|
||||||
|
|
||||||
|
const intersectBoundary = (a: Boundary, b: Boundary) => {
|
||||||
|
if (a.upperBound < b.lowerBound || a.lowerBound > b.upperBound) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
lowerBound: Math.max(b.lowerBound, a.lowerBound),
|
||||||
|
upperBound: Math.min(b.upperBound, a.upperBound),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSlots = ({
|
// say invitee is -60,1380, and boundary is -120,240 - the overlap is -60,240
|
||||||
calendarTimeZone,
|
const getOverlaps = (inviteeBoundary: Boundary, boundaries: Boundary[]) =>
|
||||||
eventLength,
|
boundaries.map((boundary) => intersectBoundary(inviteeBoundary, boundary)).filter(Boolean);
|
||||||
selectedTimeZone,
|
|
||||||
selectedDate,
|
|
||||||
dayStartTime,
|
|
||||||
dayEndTime
|
|
||||||
}) => {
|
|
||||||
|
|
||||||
if(!selectedDate) return []
|
const organizerBoundaries = (workingHours: [], inviteeDate: Dayjs, inviteeBounds: Boundary): Boundary[] => {
|
||||||
|
const boundaries: Boundary[] = [];
|
||||||
const lowerBound = selectedDate.tz(selectedTimeZone).startOf("day");
|
|
||||||
|
|
||||||
// Simple case, same timezone
|
const startDay: number = +inviteeDate
|
||||||
if (calendarTimeZone === selectedTimeZone) {
|
.utc()
|
||||||
const slots = [];
|
|
||||||
const now = dayjs();
|
|
||||||
for (
|
|
||||||
let minutes = dayStartTime;
|
|
||||||
minutes <= dayEndTime - eventLength;
|
|
||||||
minutes += parseInt(eventLength, 10)
|
|
||||||
) {
|
|
||||||
const slot = lowerBound.add(minutes, "minutes");
|
|
||||||
if (slot > now) {
|
|
||||||
slots.push(slot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return slots;
|
|
||||||
}
|
|
||||||
|
|
||||||
const upperBound = selectedDate.tz(selectedTimeZone).endOf("day");
|
|
||||||
|
|
||||||
// We need to start generating slots from the start of the calendarTimeZone day
|
|
||||||
const startDateTime = lowerBound
|
|
||||||
.tz(calendarTimeZone)
|
|
||||||
.startOf("day")
|
.startOf("day")
|
||||||
.add(dayStartTime, "minutes");
|
.add(inviteeBounds.lowerBound, "minutes")
|
||||||
|
.format("d");
|
||||||
|
const endDay: number = +inviteeDate
|
||||||
|
.utc()
|
||||||
|
.startOf("day")
|
||||||
|
.add(inviteeBounds.upperBound, "minutes")
|
||||||
|
.format("d");
|
||||||
|
|
||||||
let phase = 0;
|
workingHours.forEach((item) => {
|
||||||
if (startDateTime < lowerBound) {
|
const lowerBound: number = item.startTime;
|
||||||
// Getting minutes of the first event in the day of the chooser
|
const upperBound: number = lowerBound + item.length;
|
||||||
const diff = lowerBound.diff(startDateTime, "minutes");
|
if (startDay !== endDay) {
|
||||||
|
if (inviteeBounds.lowerBound < 0) {
|
||||||
// finding first event
|
// lowerBound edges into the previous day
|
||||||
phase = diff + eventLength - (diff % eventLength);
|
if (item.days.includes(startDay)) {
|
||||||
}
|
boundaries.push({ lowerBound: lowerBound - 1440, upperBound: upperBound - 1440 });
|
||||||
|
}
|
||||||
// We can stop as soon as the selectedTimeZone day ends
|
if (item.days.includes(endDay)) {
|
||||||
const endDateTime = upperBound
|
boundaries.push({ lowerBound, upperBound });
|
||||||
.tz(calendarTimeZone)
|
}
|
||||||
.subtract(eventLength, "minutes");
|
} else {
|
||||||
|
// upperBound edges into the next day
|
||||||
const maxMinutes = endDateTime.diff(startDateTime, "minutes");
|
if (item.days.includes(endDay)) {
|
||||||
|
boundaries.push({ lowerBound: lowerBound + 1440, upperBound: upperBound + 1440 });
|
||||||
const slots = [];
|
}
|
||||||
const now = dayjs();
|
if (item.days.includes(startDay)) {
|
||||||
for (
|
boundaries.push({ lowerBound, upperBound });
|
||||||
let minutes = phase;
|
}
|
||||||
minutes <= maxMinutes;
|
}
|
||||||
minutes += parseInt(eventLength, 10)
|
} else {
|
||||||
) {
|
boundaries.push({ lowerBound, upperBound });
|
||||||
const slot = startDateTime.add(minutes, "minutes");
|
|
||||||
|
|
||||||
const minutesFromMidnight = getMinutesFromMidnight(slot);
|
|
||||||
|
|
||||||
if (
|
|
||||||
minutesFromMidnight < dayStartTime ||
|
|
||||||
minutesFromMidnight > dayEndTime - eventLength ||
|
|
||||||
slot < now
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
return boundaries;
|
||||||
|
};
|
||||||
|
|
||||||
slots.push(slot.tz(selectedTimeZone));
|
const inviteeBoundary = (startTime: number, utcOffset: number, frequency: number): Boundary => {
|
||||||
|
const upperBound: number = freqApply(Math.floor, 1440 - utcOffset, frequency);
|
||||||
|
const lowerBound: number = freqApply(Math.ceil, startTime - utcOffset, frequency);
|
||||||
|
return {
|
||||||
|
lowerBound,
|
||||||
|
upperBound,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSlotsBetweenBoundary = (frequency: number, { lowerBound, upperBound }: Boundary) => {
|
||||||
|
const slots: Dayjs[] = [];
|
||||||
|
for (let minutes = 0; lowerBound + minutes <= upperBound - frequency; minutes += frequency) {
|
||||||
|
slots.push(
|
||||||
|
<Dayjs>dayjs
|
||||||
|
.utc()
|
||||||
|
.startOf("day")
|
||||||
|
.add(lowerBound + minutes, "minutes")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return slots;
|
return slots;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default getSlots
|
const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours }: GetSlotsType): Dayjs[] => {
|
||||||
|
const startTime = dayjs.utc().isSame(dayjs(inviteeDate), "day")
|
||||||
|
? inviteeDate.hour() * 60 + inviteeDate.minute() + minimumBookingNotice
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const inviteeBounds = inviteeBoundary(startTime, inviteeDate.utcOffset(), frequency);
|
||||||
|
return getOverlaps(inviteeBounds, organizerBoundaries(workingHours, inviteeDate, inviteeBounds))
|
||||||
|
.reduce((slots, boundary: Boundary) => [...slots, ...getSlotsBetweenBoundary(frequency, boundary)], [])
|
||||||
|
.map((slot) => slot.utcOffset(dayjs(inviteeDate).utcOffset()));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getSlots;
|
||||||
|
|
18
package.json
18
package.json
|
@ -4,6 +4,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
"test": "node --experimental-vm-modules node_modules/.bin/jest",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"postinstall": "prisma generate",
|
"postinstall": "prisma generate",
|
||||||
|
@ -36,6 +37,7 @@
|
||||||
"uuid": "^8.3.2"
|
"uuid": "^8.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/jest": "^26.0.23",
|
||||||
"@types/node": "^14.14.33",
|
"@types/node": "^14.14.33",
|
||||||
"@types/react": "^17.0.3",
|
"@types/react": "^17.0.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.27.0",
|
"@typescript-eslint/eslint-plugin": "^4.27.0",
|
||||||
|
@ -47,7 +49,9 @@
|
||||||
"eslint-plugin-react": "^7.24.0",
|
"eslint-plugin-react": "^7.24.0",
|
||||||
"eslint-plugin-react-hooks": "^4.2.0",
|
"eslint-plugin-react-hooks": "^4.2.0",
|
||||||
"husky": "^6.0.0",
|
"husky": "^6.0.0",
|
||||||
|
"jest": "^27.0.5",
|
||||||
"lint-staged": "^11.0.0",
|
"lint-staged": "^11.0.0",
|
||||||
|
"mockdate": "^3.0.5",
|
||||||
"postcss": "^8.2.8",
|
"postcss": "^8.2.8",
|
||||||
"prettier": "^2.3.1",
|
"prettier": "^2.3.1",
|
||||||
"prisma": "^2.23.0",
|
"prisma": "^2.23.0",
|
||||||
|
@ -59,5 +63,19 @@
|
||||||
"prettier --write",
|
"prettier --write",
|
||||||
"eslint"
|
"eslint"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"verbose": true,
|
||||||
|
"extensionsToTreatAsEsm": [
|
||||||
|
".ts"
|
||||||
|
],
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^@components(.*)$": "<rootDir>/components$1",
|
||||||
|
"^@lib(.*)$": "<rootDir>/lib$1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,58 +1,64 @@
|
||||||
import {useEffect, useState, useMemo} from 'react';
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import Head from 'next/head';
|
import Head from "next/head";
|
||||||
import Link from 'next/link';
|
import prisma from "../../lib/prisma";
|
||||||
import prisma from '../../lib/prisma';
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import dayjs, { Dayjs } from 'dayjs';
|
import { ClockIcon, GlobeIcon, ChevronDownIcon } from "@heroicons/react/solid";
|
||||||
import { ClockIcon, GlobeIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid';
|
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
|
||||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
import utc from "dayjs/plugin/utc";
|
||||||
import utc from 'dayjs/plugin/utc';
|
import timezone from "dayjs/plugin/timezone";
|
||||||
import timezone from 'dayjs/plugin/timezone';
|
|
||||||
dayjs.extend(isSameOrBefore);
|
dayjs.extend(isSameOrBefore);
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
|
||||||
import AvailableTimes from "../../components/booking/AvailableTimes";
|
import AvailableTimes from "../../components/booking/AvailableTimes";
|
||||||
import TimeOptions from "../../components/booking/TimeOptions"
|
import TimeOptions from "../../components/booking/TimeOptions";
|
||||||
import Avatar from '../../components/Avatar';
|
import Avatar from "../../components/Avatar";
|
||||||
import {timeZone} from "../../lib/clock";
|
import { timeZone } from "../../lib/clock";
|
||||||
import DatePicker from "../../components/booking/DatePicker";
|
import DatePicker from "../../components/booking/DatePicker";
|
||||||
import PoweredByCalendso from "../../components/ui/PoweredByCalendso";
|
import PoweredByCalendso from "../../components/ui/PoweredByCalendso";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import getSlots from "@lib/slots";
|
||||||
|
|
||||||
export default function Type(props) {
|
export default function Type(props) {
|
||||||
|
|
||||||
// Get router variables
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { rescheduleUid } = router.query;
|
const { rescheduleUid } = router.query;
|
||||||
|
|
||||||
// Initialise state
|
|
||||||
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
||||||
const [selectedMonth, setSelectedMonth] = useState(dayjs().month());
|
|
||||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||||
const [timeFormat, setTimeFormat] = useState('h:mma');
|
const [timeFormat, setTimeFormat] = useState("h:mma");
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
|
const today: string = dayjs().utc().format("YYYY-MM-DDTHH:mm");
|
||||||
|
const noSlotsToday = useMemo(
|
||||||
|
() =>
|
||||||
|
getSlots({
|
||||||
|
frequency: props.eventType.length,
|
||||||
|
inviteeDate: dayjs.utc(today) as Dayjs,
|
||||||
|
workingHours: props.workingHours,
|
||||||
|
minimumBookingNotice: 0,
|
||||||
|
}).length === 0,
|
||||||
|
[today, props.eventType.length, props.workingHours]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters()))
|
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters()));
|
||||||
}, []);
|
}, [telemetry]);
|
||||||
|
|
||||||
const changeDate = (date: Dayjs) => {
|
const changeDate = (date: Dayjs) => {
|
||||||
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters()))
|
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters()));
|
||||||
setSelectedDate(date);
|
setSelectedDate(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectTimeZone = (selectedTimeZone: string) => {
|
const handleSelectTimeZone = (selectedTimeZone: string) => {
|
||||||
if (selectedDate) {
|
if (selectedDate) {
|
||||||
setSelectedDate(selectedDate.tz(selectedTimeZone))
|
setSelectedDate(selectedDate.tz(selectedTimeZone));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggle24hClock = (is24hClock: boolean) => {
|
const handleToggle24hClock = (is24hClock: boolean) => {
|
||||||
if (selectedDate) {
|
setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
|
||||||
setTimeFormat(is24hClock ? 'HH:mm' : 'h:mma');
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
@ -67,40 +73,47 @@ export default function Type(props) {
|
||||||
className={
|
className={
|
||||||
"mx-auto my-24 transition-max-width ease-in-out duration-500 " +
|
"mx-auto my-24 transition-max-width ease-in-out duration-500 " +
|
||||||
(selectedDate ? "max-w-6xl" : "max-w-3xl")
|
(selectedDate ? "max-w-6xl" : "max-w-3xl")
|
||||||
}
|
}>
|
||||||
>
|
|
||||||
<div className="bg-white shadow rounded-lg">
|
<div className="bg-white shadow rounded-lg">
|
||||||
<div className="sm:flex px-4 py-5 sm:p-4">
|
<div className="sm:flex px-4 py-5 sm:p-4">
|
||||||
<div
|
<div className={"pr-8 sm:border-r " + (selectedDate ? "sm:w-1/3" : "sm:w-1/2")}>
|
||||||
className={
|
|
||||||
"pr-8 sm:border-r " + (selectedDate ? "sm:w-1/3" : "sm:w-1/2")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" />
|
<Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" />
|
||||||
<h2 className="font-medium text-gray-500">{props.user.name}</h2>
|
<h2 className="font-medium text-gray-500">{props.user.name}</h2>
|
||||||
<h1 className="text-3xl font-semibold text-gray-800 mb-4">
|
<h1 className="text-3xl font-semibold text-gray-800 mb-4">{props.eventType.title}</h1>
|
||||||
{props.eventType.title}
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 mb-1 px-2 py-1 -ml-2">
|
<p className="text-gray-500 mb-1 px-2 py-1 -ml-2">
|
||||||
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
{props.eventType.length} minutes
|
{props.eventType.length} minutes
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsTimeOptionsOpen(true)}
|
onClick={() => setIsTimeOptionsOpen(true)}
|
||||||
className="text-gray-500 mb-1 px-2 py-1 -ml-2"
|
className="text-gray-500 mb-1 px-2 py-1 -ml-2">
|
||||||
>
|
<GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
<GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
{timeZone()}
|
||||||
{timeZone()}
|
<ChevronDownIcon className="inline-block w-4 h-4 ml-1 -mt-1" />
|
||||||
<ChevronDownIcon className="inline-block w-4 h-4 ml-1 -mt-1" />
|
</button>
|
||||||
</button>
|
{isTimeOptionsOpen && (
|
||||||
{ isTimeOptionsOpen && <TimeOptions onSelectTimeZone={handleSelectTimeZone}
|
<TimeOptions
|
||||||
onToggle24hClock={handleToggle24hClock} />}
|
onSelectTimeZone={handleSelectTimeZone}
|
||||||
<p className="text-gray-600 mt-3 mb-8">
|
onToggle24hClock={handleToggle24hClock}
|
||||||
{props.eventType.description}
|
/>
|
||||||
</p>
|
)}
|
||||||
</div>
|
<p className="text-gray-600 mt-3 mb-8">{props.eventType.description}</p>
|
||||||
<DatePicker weekStart={props.user.weekStart} onDatePicked={changeDate} />
|
</div>
|
||||||
{selectedDate && <AvailableTimes timeFormat={timeFormat} user={props.user} eventType={props.eventType} date={selectedDate} />}
|
<DatePicker
|
||||||
|
disableToday={noSlotsToday}
|
||||||
|
weekStart={props.user.weekStart}
|
||||||
|
onDatePicked={changeDate}
|
||||||
|
workingHours={props.workingHours}
|
||||||
|
/>
|
||||||
|
{selectedDate && (
|
||||||
|
<AvailableTimes
|
||||||
|
workingHours={props.workingHours}
|
||||||
|
timeFormat={timeFormat}
|
||||||
|
eventLength={props.eventType.length}
|
||||||
|
eventTypeId={props.eventType.id}
|
||||||
|
date={selectedDate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* note(peer):
|
{/* note(peer):
|
||||||
|
@ -112,6 +125,14 @@ export default function Type(props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WorkingHours {
|
||||||
|
days: number[];
|
||||||
|
startTime: number;
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Availability = WorkingHours;
|
||||||
|
|
||||||
export async function getServerSideProps(context) {
|
export async function getServerSideProps(context) {
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
@ -129,13 +150,14 @@ export async function getServerSideProps(context) {
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
endTime: true,
|
endTime: true,
|
||||||
weekStart: true,
|
weekStart: true,
|
||||||
}
|
availability: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventType = await prisma.eventType.findFirst({
|
const eventType = await prisma.eventType.findFirst({
|
||||||
|
@ -149,20 +171,38 @@ export async function getServerSideProps(context) {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
length: true
|
length: true,
|
||||||
}
|
availability: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!eventType) {
|
if (!eventType) {
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getWorkingHours = (providesAvailability) =>
|
||||||
|
providesAvailability.availability && providesAvailability.availability.length
|
||||||
|
? providesAvailability.availability
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const workingHours: WorkingHours[] =
|
||||||
|
getWorkingHours(eventType) ||
|
||||||
|
getWorkingHours(user) ||
|
||||||
|
[
|
||||||
|
{
|
||||||
|
days: [1, 2, 3, 4, 5, 6, 7],
|
||||||
|
startTime: user.startTime,
|
||||||
|
length: user.endTime,
|
||||||
|
},
|
||||||
|
].filter((availability: Availability): boolean => typeof availability["days"] !== "undefined");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
user,
|
user,
|
||||||
eventType,
|
eventType,
|
||||||
|
workingHours,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load diff
39
test/lib/slots.test.ts
Normal file
39
test/lib/slots.test.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import getSlots from '@lib/slots';
|
||||||
|
import {it, expect} from '@jest/globals';
|
||||||
|
import MockDate from 'mockdate';
|
||||||
|
import dayjs, {Dayjs} from 'dayjs';
|
||||||
|
import utc from 'dayjs/plugin/utc';
|
||||||
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
|
MockDate.set('2021-06-20T12:00:00Z');
|
||||||
|
|
||||||
|
it('can fit 24 hourly slots for an empty day', async () => {
|
||||||
|
// 24h in a day.
|
||||||
|
expect(getSlots({
|
||||||
|
inviteeDate: dayjs().add(1, 'day'),
|
||||||
|
length: 60,
|
||||||
|
})).toHaveLength(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has slots that be in the same timezone as the invitee', async() => {
|
||||||
|
expect(getSlots({
|
||||||
|
inviteeDate: dayjs().add(1, 'day'),
|
||||||
|
length: 60
|
||||||
|
})[0].utcOffset()).toBe(-0);
|
||||||
|
|
||||||
|
expect(getSlots({
|
||||||
|
inviteeDate: dayjs().tz('Europe/London').add(1, 'day'),
|
||||||
|
length: 60
|
||||||
|
})[0].utcOffset()).toBe(dayjs().tz('Europe/London').utcOffset());
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes slots that have already passed when invitee day equals today', async () => {
|
||||||
|
expect(getSlots({ inviteeDate: dayjs(), length: 60 })).toHaveLength(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports having slots in different utc offset than the invitee', async () => {
|
||||||
|
expect(getSlots({ inviteeDate: dayjs(), length: 60 })).toHaveLength(12);
|
||||||
|
expect(getSlots({ inviteeDate: dayjs().tz('Europe/Brussels'), length: 60 })).toHaveLength(14);
|
||||||
|
});
|
|
@ -6,6 +6,11 @@
|
||||||
"dom.iterable",
|
"dom.iterable",
|
||||||
"esnext"
|
"esnext"
|
||||||
],
|
],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@components/*": ["components/*"],
|
||||||
|
"@lib/*": ["lib/*"]
|
||||||
|
},
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": false,
|
"strict": false,
|
||||||
|
|
Loading…
Reference in a new issue