Skip to content

Commit d701d39

Browse files
authored
Merge pull request #293 from emrysal/feature/scheduling
Feature/scheduling
2 parents 80898ea + a7173a3 commit d701d39

23 files changed

+3502
-1022
lines changed

.babelrc

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"presets": ["next/babel"]
3+
}

.eslintrc.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
"env": {
2525
"browser": true,
2626
"node": true,
27-
"es6": true
27+
"es6": true,
28+
"jest": true
2829
},
2930
"settings": {
3031
"react": {

components/booking/AvailableTimes.tsx

+19-91
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,40 @@
1-
import dayjs from "dayjs";
2-
import isBetween from "dayjs/plugin/isBetween";
3-
dayjs.extend(isBetween);
4-
import { useEffect, useState, useMemo } from "react";
5-
import getSlots from "../../lib/slots";
61
import Link from "next/link";
7-
import { timeZone } from "../../lib/clock";
82
import { useRouter } from "next/router";
3+
import Slots from "./Slots";
94
import { ExclamationIcon } from "@heroicons/react/solid";
105

11-
const AvailableTimes = (props) => {
6+
const AvailableTimes = ({ date, eventLength, eventTypeId, workingHours, timeFormat, user }) => {
127
const router = useRouter();
13-
const { user, rescheduleUid } = router.query;
14-
const [loaded, setLoaded] = useState(false);
15-
const [error, setError] = useState(false);
16-
17-
const times = useMemo(() => {
18-
const slots = getSlots({
19-
calendarTimeZone: props.user.timeZone,
20-
selectedTimeZone: timeZone(),
21-
eventLength: props.eventType.length,
22-
selectedDate: props.date,
23-
dayStartTime: props.user.startTime,
24-
dayEndTime: props.user.endTime,
25-
});
26-
27-
return slots;
28-
}, [props.date]);
29-
30-
const handleAvailableSlots = (busyTimes: []) => {
31-
// Check for conflicts
32-
for (let i = times.length - 1; i >= 0; i -= 1) {
33-
busyTimes.forEach((busyTime) => {
34-
const startTime = dayjs(busyTime.start);
35-
const endTime = dayjs(busyTime.end);
36-
37-
// Check if start times are the same
38-
if (dayjs(times[i]).format("HH:mm") == startTime.format("HH:mm")) {
39-
times.splice(i, 1);
40-
}
41-
42-
// Check if time is between start and end times
43-
if (dayjs(times[i]).isBetween(startTime, endTime)) {
44-
times.splice(i, 1);
45-
}
46-
47-
// Check if slot end time is between start and end time
48-
if (dayjs(times[i]).add(props.eventType.length, "minutes").isBetween(startTime, endTime)) {
49-
times.splice(i, 1);
50-
}
51-
52-
// Check if startTime is between slot
53-
if (startTime.isBetween(dayjs(times[i]), dayjs(times[i]).add(props.eventType.length, "minutes"))) {
54-
times.splice(i, 1);
55-
}
56-
});
57-
}
58-
// Display available times
59-
setLoaded(true);
60-
};
61-
62-
// Re-render only when invitee changes date
63-
useEffect(() => {
64-
setLoaded(false);
65-
setError(false);
66-
fetch(
67-
`/api/availability/${user}?dateFrom=${props.date.startOf("day").utc().format()}&dateTo=${props.date
68-
.endOf("day")
69-
.utc()
70-
.format()}`
71-
)
72-
.then((res) => res.json())
73-
.then(handleAvailableSlots)
74-
.catch((e) => {
75-
console.error(e);
76-
setError(true);
77-
});
78-
}, [props.date]);
79-
8+
const { rescheduleUid } = router.query;
9+
const { slots, isFullyBooked, hasErrors } = Slots({ date, eventLength, workingHours });
8010
return (
8111
<div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:max-h-97 overflow-y-auto">
8212
<div className="text-gray-600 font-light text-xl mb-4 text-left">
83-
<span className="w-1/2">{props.date.format("dddd DD MMMM YYYY")}</span>
13+
<span className="w-1/2">{date.format("dddd DD MMMM YYYY")}</span>
8414
</div>
85-
{!error &&
86-
loaded &&
87-
times.length > 0 &&
88-
times.map((time) => (
89-
<div key={dayjs(time).utc().format()}>
15+
{slots.length > 0 &&
16+
slots.map((slot) => (
17+
<div key={slot.format()}>
9018
<Link
9119
href={
92-
`/${props.user.username}/book?date=${dayjs(time).utc().format()}&type=${props.eventType.id}` +
20+
`/${user.username}/book?date=${slot.utc().format()}&type=${eventTypeId}` +
9321
(rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "")
9422
}>
95-
<a
96-
key={dayjs(time).format("hh:mma")}
97-
className="block font-medium mb-4 text-blue-600 border border-blue-600 rounded hover:text-white hover:bg-blue-600 py-4">
98-
{dayjs(time).tz(timeZone()).format(props.timeFormat)}
23+
<a className="block font-medium mb-4 text-blue-600 border border-blue-600 rounded hover:text-white hover:bg-blue-600 py-4">
24+
{slot.format(timeFormat)}
9925
</a>
10026
</Link>
10127
</div>
10228
))}
103-
{!error && loaded && times.length == 0 && (
29+
{isFullyBooked && (
10430
<div className="w-full h-full flex flex-col justify-center content-center items-center -mt-4">
105-
<h1 className="text-xl font">{props.user.name} is all booked today.</h1>
31+
<h1 className="text-xl font">{user.name} is all booked today.</h1>
10632
</div>
10733
)}
108-
{!error && !loaded && <div className="loader" />}
109-
{error && (
34+
35+
{!isFullyBooked && slots.length === 0 && !hasErrors && <div className="loader" />}
36+
37+
{hasErrors && (
11038
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4">
11139
<div className="flex">
11240
<div className="flex-shrink-0">
@@ -116,9 +44,9 @@ const AvailableTimes = (props) => {
11644
<p className="text-sm text-yellow-700">
11745
Could not load the available time slots.{" "}
11846
<a
119-
href={"mailto:" + props.user.email}
47+
href={"mailto:" + user.email}
12048
className="font-medium underline text-yellow-700 hover:text-yellow-600">
121-
Contact {props.user.name} via e-mail
49+
Contact {user.name} via e-mail
12250
</a>
12351
</p>
12452
</div>

components/booking/DatePicker.tsx

+134
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
2+
import { useEffect, useState } from "react";
3+
import dayjs, { Dayjs } from "dayjs";
4+
import utc from "dayjs/plugin/utc";
5+
import timezone from "dayjs/plugin/timezone";
6+
import getSlots from "@lib/slots";
7+
dayjs.extend(utc);
8+
dayjs.extend(timezone);
9+
10+
const DatePicker = ({
11+
weekStart,
12+
onDatePicked,
13+
workingHours,
14+
organizerTimeZone,
15+
inviteeTimeZone,
16+
eventLength,
17+
}) => {
18+
const [calendar, setCalendar] = useState([]);
19+
const [selectedMonth, setSelectedMonth]: number = useState();
20+
const [selectedDate, setSelectedDate]: Dayjs = useState();
21+
22+
useEffect(() => {
23+
setSelectedMonth(dayjs().tz(inviteeTimeZone).month());
24+
}, []);
25+
26+
useEffect(() => {
27+
if (selectedDate) onDatePicked(selectedDate);
28+
}, [selectedDate]);
29+
30+
// Handle month changes
31+
const incrementMonth = () => {
32+
setSelectedMonth(selectedMonth + 1);
33+
};
34+
35+
const decrementMonth = () => {
36+
setSelectedMonth(selectedMonth - 1);
37+
};
38+
39+
useEffect(() => {
40+
if (!selectedMonth) {
41+
// wish next had a way of dealing with this magically;
42+
return;
43+
}
44+
45+
const inviteeDate = dayjs().tz(inviteeTimeZone).month(selectedMonth);
46+
47+
const isDisabled = (day: number) => {
48+
const date: Dayjs = inviteeDate.date(day);
49+
return (
50+
date.endOf("day").isBefore(dayjs().tz(inviteeTimeZone)) ||
51+
!getSlots({
52+
inviteeDate: date,
53+
frequency: eventLength,
54+
workingHours,
55+
organizerTimeZone,
56+
}).length
57+
);
58+
};
59+
60+
// Set up calendar
61+
const daysInMonth = inviteeDate.daysInMonth();
62+
const days = [];
63+
for (let i = 1; i <= daysInMonth; i++) {
64+
days.push(i);
65+
}
66+
67+
// Create placeholder elements for empty days in first week
68+
let weekdayOfFirst = inviteeDate.date(1).day();
69+
if (weekStart === "Monday") {
70+
weekdayOfFirst -= 1;
71+
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
72+
}
73+
const emptyDays = Array(weekdayOfFirst)
74+
.fill(null)
75+
.map((day, i) => (
76+
<div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}>
77+
{null}
78+
</div>
79+
));
80+
81+
// Combine placeholder days with actual days
82+
setCalendar([
83+
...emptyDays,
84+
...days.map((day) => (
85+
<button
86+
key={day}
87+
onClick={() => setSelectedDate(inviteeDate.date(day))}
88+
disabled={isDisabled(day)}
89+
className={
90+
"text-center w-10 h-10 rounded-full mx-auto" +
91+
(isDisabled(day) ? " text-gray-400 font-light" : " text-blue-600 font-medium") +
92+
(selectedDate && selectedDate.isSame(inviteeDate.date(day), "day")
93+
? " bg-blue-600 text-white-important"
94+
: !isDisabled(day)
95+
? " bg-blue-50"
96+
: "")
97+
}>
98+
{day}
99+
</button>
100+
)),
101+
]);
102+
}, [selectedMonth, inviteeTimeZone, selectedDate]);
103+
104+
return selectedMonth ? (
105+
<div className={"mt-8 sm:mt-0 " + (selectedDate ? "sm:w-1/3 border-r sm:px-4" : "sm:w-1/2 sm:pl-4")}>
106+
<div className="flex text-gray-600 font-light text-xl mb-4 ml-2">
107+
<span className="w-1/2">{dayjs().month(selectedMonth).format("MMMM YYYY")}</span>
108+
<div className="w-1/2 text-right">
109+
<button
110+
onClick={decrementMonth}
111+
className={"mr-4 " + (selectedMonth <= dayjs().tz(inviteeTimeZone).month() && "text-gray-400")}
112+
disabled={selectedMonth <= dayjs().tz(inviteeTimeZone).month()}>
113+
<ChevronLeftIcon className="w-5 h-5" />
114+
</button>
115+
<button onClick={incrementMonth}>
116+
<ChevronRightIcon className="w-5 h-5" />
117+
</button>
118+
</div>
119+
</div>
120+
<div className="grid grid-cols-7 gap-y-4 text-center">
121+
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
122+
.sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0))
123+
.map((weekDay) => (
124+
<div key={weekDay} className="uppercase text-gray-400 text-xs tracking-widest">
125+
{weekDay}
126+
</div>
127+
))}
128+
{calendar}
129+
</div>
130+
</div>
131+
) : null;
132+
};
133+
134+
export default DatePicker;

components/booking/Slots.tsx

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { useState, useEffect } from "react";
2+
import { useRouter } from "next/router";
3+
import getSlots from "../../lib/slots";
4+
import dayjs, { Dayjs } from "dayjs";
5+
import isBetween from "dayjs/plugin/isBetween";
6+
import utc from "dayjs/plugin/utc";
7+
dayjs.extend(isBetween);
8+
dayjs.extend(utc);
9+
10+
type Props = {
11+
eventLength: number;
12+
minimumBookingNotice?: number;
13+
date: Dayjs;
14+
};
15+
16+
const Slots = ({ eventLength, minimumBookingNotice, date, workingHours, organizerUtcOffset }: Props) => {
17+
minimumBookingNotice = minimumBookingNotice || 0;
18+
19+
const router = useRouter();
20+
const { user } = router.query;
21+
const [slots, setSlots] = useState([]);
22+
const [isFullyBooked, setIsFullyBooked] = useState(false);
23+
const [hasErrors, setHasErrors] = useState(false);
24+
25+
useEffect(() => {
26+
setSlots([]);
27+
setIsFullyBooked(false);
28+
setHasErrors(false);
29+
fetch(
30+
`/api/availability/${user}?dateFrom=${date.startOf("day").utc().startOf("day").format()}&dateTo=${date
31+
.endOf("day")
32+
.utc()
33+
.endOf("day")
34+
.format()}`
35+
)
36+
.then((res) => res.json())
37+
.then(handleAvailableSlots)
38+
.catch((e) => {
39+
console.error(e);
40+
setHasErrors(true);
41+
});
42+
}, [date]);
43+
44+
const handleAvailableSlots = (busyTimes: []) => {
45+
const times = getSlots({
46+
frequency: eventLength,
47+
inviteeDate: date,
48+
workingHours,
49+
minimumBookingNotice,
50+
organizerUtcOffset,
51+
});
52+
53+
const timesLengthBeforeConflicts: number = times.length;
54+
55+
// Check for conflicts
56+
for (let i = times.length - 1; i >= 0; i -= 1) {
57+
busyTimes.every((busyTime): boolean => {
58+
const startTime = dayjs(busyTime.start).utc();
59+
const endTime = dayjs(busyTime.end).utc();
60+
// Check if start times are the same
61+
if (times[i].utc().isSame(startTime)) {
62+
times.splice(i, 1);
63+
}
64+
// Check if time is between start and end times
65+
else if (times[i].utc().isBetween(startTime, endTime)) {
66+
times.splice(i, 1);
67+
}
68+
// Check if slot end time is between start and end time
69+
else if (times[i].utc().add(eventLength, "minutes").isBetween(startTime, endTime)) {
70+
times.splice(i, 1);
71+
}
72+
// Check if startTime is between slot
73+
else if (startTime.isBetween(times[i].utc(), times[i].utc().add(eventLength, "minutes"))) {
74+
times.splice(i, 1);
75+
} else {
76+
return true;
77+
}
78+
return false;
79+
});
80+
}
81+
82+
if (times.length === 0 && timesLengthBeforeConflicts !== 0) {
83+
setIsFullyBooked(true);
84+
}
85+
// Display available times
86+
setSlots(times);
87+
};
88+
89+
return {
90+
slots,
91+
isFullyBooked,
92+
hasErrors,
93+
};
94+
};
95+
96+
export default Slots;

0 commit comments

Comments
 (0)