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"); |   const startDay: number = +inviteeDate | ||||||
| 
 |     .utc() | ||||||
|   // Simple case, same timezone
 |  | ||||||
|   if (calendarTimeZone === selectedTimeZone) { |  | ||||||
|     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
	
	 Alex van Andel
						Alex van Andel