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": { | ||||
|     "prettier/prettier": ["error"], | ||||
|     "@typescript-eslint/no-unused-vars": "error", | ||||
|     "react/react-in-jsx-scope": "off" | ||||
|     "react/react-in-jsx-scope": "off", | ||||
|     "react/prop-types": "off" | ||||
|   }, | ||||
|   "env": { | ||||
|     "browser": true, | ||||
|     "node": true, | ||||
|     "es6": true | ||||
|     "es6": true, | ||||
|     "jest": true | ||||
|   }, | ||||
|   "settings": { | ||||
|     "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 {timeZone} from "../../lib/clock"; | ||||
| import {useRouter} from "next/router"; | ||||
| 
 | ||||
| const AvailableTimes = (props) => { | ||||
| import { useRouter } from "next/router"; | ||||
| import Slots from "./Slots"; | ||||
| 
 | ||||
| const AvailableTimes = ({ date, eventLength, eventTypeId, workingHours, timeFormat }) => { | ||||
|   const router = useRouter(); | ||||
|   const { user, rescheduleUid } = router.query; | ||||
|   const [loaded, setLoaded] = useState(false); | ||||
| 
 | ||||
|   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]); | ||||
| 
 | ||||
|   const { slots } = Slots({ date, eventLength, workingHours }); | ||||
|   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="text-gray-600 font-light text-xl mb-4 text-left"> | ||||
|         <span className="w-1/2"> | ||||
|           {props.date.format("dddd DD MMMM YYYY")} | ||||
|         </span> | ||||
|         <span className="w-1/2">{date.format("dddd DD MMMM YYYY")}</span> | ||||
|       </div> | ||||
|       { | ||||
|         loaded ? times.map((time) => | ||||
|           <div key={dayjs(time).utc().format()}> | ||||
|       {slots.length > 0 ? ( | ||||
|         slots.map((slot) => ( | ||||
|           <div key={slot.format()}> | ||||
|             <Link | ||||
|               href={`/${props.user.username}/book?date=${dayjs(time).utc().format()}&type=${props.eventType.id}` + (rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "")}> | ||||
|               <a key={dayjs(time).format("hh:mma")} | ||||
|                  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> | ||||
|               href={ | ||||
|                 `/${user}/book?date=${slot.utc().format()}&type=${eventTypeId}` + | ||||
|                 (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> | ||||
|           </div> | ||||
|         ) : <div className="loader"></div> | ||||
|       } | ||||
|         )) | ||||
|       ) : ( | ||||
|         <div className="loader" /> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| }; | ||||
| 
 | ||||
| export default AvailableTimes; | ||||
|  |  | |||
|  | @ -1,90 +1,90 @@ | |||
| import dayjs from "dayjs"; | ||||
| import {ChevronLeftIcon, ChevronRightIcon} from "@heroicons/react/solid"; | ||||
| import {useEffect, useState} from "react"; | ||||
| 
 | ||||
| const DatePicker = ({ weekStart, onDatePicked }) => { | ||||
| import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import dayjs, { Dayjs } from "dayjs"; | ||||
| import isToday from "dayjs/plugin/isToday"; | ||||
| 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 [selectedDay, setSelectedDay] = useState(dayjs().date()); | ||||
|   const [hasPickedDate, setHasPickedDate] = useState(false); | ||||
|   const [selectedDate, setSelectedDate] = useState(); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (hasPickedDate) { | ||||
|       onDatePicked(dayjs().month(selectedMonth).date(selectedDay)); | ||||
|     } | ||||
|   }, [hasPickedDate, selectedDay]); | ||||
|     if (selectedDate) onDatePicked(selectedDate); | ||||
|   }, [selectedDate, onDatePicked]); | ||||
| 
 | ||||
|   // Handle month changes
 | ||||
|   const incrementMonth = () => { | ||||
|     setSelectedMonth(selectedMonth + 1); | ||||
|   } | ||||
|   }; | ||||
| 
 | ||||
|   const decrementMonth = () => { | ||||
|     setSelectedMonth(selectedMonth - 1); | ||||
|   } | ||||
|   }; | ||||
| 
 | ||||
|   // Set up calendar
 | ||||
|   var daysInMonth = dayjs().month(selectedMonth).daysInMonth(); | ||||
|   var days = []; | ||||
|   const daysInMonth = dayjs().month(selectedMonth).daysInMonth(); | ||||
|   const days = []; | ||||
|   for (let i = 1; i <= daysInMonth; i++) { | ||||
|     days.push(i); | ||||
|   } | ||||
| 
 | ||||
|   // Create placeholder elements for empty days in first week
 | ||||
|   let weekdayOfFirst = dayjs().month(selectedMonth).date(1).day(); | ||||
|   if (weekStart === 'Monday') { | ||||
|   if (weekStart === "Monday") { | ||||
|     weekdayOfFirst -= 1; | ||||
|     if (weekdayOfFirst < 0) | ||||
|       weekdayOfFirst = 6; | ||||
|     if (weekdayOfFirst < 0) weekdayOfFirst = 6; | ||||
|   } | ||||
|   const emptyDays = Array(weekdayOfFirst).fill(null).map((day, i) => | ||||
|     <div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}> | ||||
|       {null} | ||||
|     </div> | ||||
|   ); | ||||
|   const emptyDays = Array(weekdayOfFirst) | ||||
|     .fill(null) | ||||
|     .map((day, i) => ( | ||||
|       <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
 | ||||
|   const calendar = [...emptyDays, ...days.map((day) => | ||||
|     <button key={day} | ||||
|             onClick={() => { setHasPickedDate(true); setSelectedDay(day) }} | ||||
|             disabled={ | ||||
|               selectedMonth < parseInt(dayjs().format('MM')) && dayjs().month(selectedMonth).format("D") > day | ||||
|             } | ||||
|             className={ | ||||
|               "text-center w-10 h-10 rounded-full mx-auto " + ( | ||||
|                 dayjs().isSameOrBefore(dayjs().date(day).month(selectedMonth) | ||||
|                 ) ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-400 font-light' | ||||
|               ) + ( | ||||
|                 dayjs().date(selectedDay).month(selectedMonth).format("D") == day ? ' bg-blue-600 text-white-important' : '' | ||||
|               ) | ||||
|             }> | ||||
|       {day} | ||||
|     </button> | ||||
|   )]; | ||||
|   const calendar = [ | ||||
|     ...emptyDays, | ||||
|     ...days.map((day) => ( | ||||
|       <button | ||||
|         key={day} | ||||
|         onClick={() => setSelectedDate(dayjs().month(selectedMonth).date(day))} | ||||
|         disabled={ | ||||
|           (selectedMonth < parseInt(dayjs().format("MM")) && | ||||
|             dayjs().month(selectedMonth).format("D") > day) || | ||||
|           isDisabled(day) | ||||
|         } | ||||
|         className={ | ||||
|           "text-center w-10 h-10 rounded-full mx-auto" + | ||||
|           (isDisabled(day) ? " text-gray-400 font-light" : " text-blue-600 font-medium") + | ||||
|           (selectedDate && selectedDate.isSame(dayjs().month(selectedMonth).date(day), "day") | ||||
|             ? " bg-blue-600 text-white-important" | ||||
|             : !isDisabled(day) | ||||
|             ? " bg-blue-50" | ||||
|             : "") | ||||
|         }> | ||||
|         {day} | ||||
|       </button> | ||||
|     )), | ||||
|   ]; | ||||
| 
 | ||||
|   return ( | ||||
|     <div | ||||
|       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={"mt-8 sm:mt-0 " + (selectedDate ? "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"> | ||||
|                   <span className="w-1/2"> | ||||
|                     {dayjs().month(selectedMonth).format("MMMM YYYY")} | ||||
|                   </span> | ||||
|         <span className="w-1/2">{dayjs().month(selectedMonth).format("MMMM YYYY")}</span> | ||||
|         <div className="w-1/2 text-right"> | ||||
|           <button | ||||
|             onClick={decrementMonth} | ||||
|             className={ | ||||
|               "mr-4 " + | ||||
|               (selectedMonth < parseInt(dayjs().format("MM")) && | ||||
|                 "text-gray-400") | ||||
|             } | ||||
|             disabled={selectedMonth < parseInt(dayjs().format("MM"))} | ||||
|           > | ||||
|             className={"mr-4 " + (selectedMonth < parseInt(dayjs().format("MM")) && "text-gray-400")} | ||||
|             disabled={selectedMonth < parseInt(dayjs().format("MM"))}> | ||||
|             <ChevronLeftIcon className="w-5 h-5" /> | ||||
|           </button> | ||||
|           <button onClick={incrementMonth}> | ||||
|  | @ -93,17 +93,17 @@ const DatePicker = ({ weekStart, onDatePicked }) => { | |||
|         </div> | ||||
|       </div> | ||||
|       <div className="grid grid-cols-7 gap-y-4 text-center"> | ||||
|         { | ||||
|           ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] | ||||
|             .sort( (a, b) => weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0 ) | ||||
|             .map( (weekDay) => | ||||
|               <div key={weekDay} className="uppercase text-gray-400 text-xs tracking-widest">{weekDay}</div> | ||||
|             ) | ||||
|         } | ||||
|         {["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] | ||||
|           .sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0)) | ||||
|           .map((weekDay) => ( | ||||
|             <div key={weekDay} className="uppercase text-gray-400 text-xs tracking-widest"> | ||||
|               {weekDay} | ||||
|             </div> | ||||
|           ))} | ||||
|         {calendar} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| }; | ||||
| 
 | ||||
| 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 {PencilAltIcon, TrashIcon} from "@heroicons/react/outline"; | ||||
| import {WeekdaySelect, Weekday} from "./WeekdaySelect"; | ||||
| import { TrashIcon } from "@heroicons/react/outline"; | ||||
| import { WeekdaySelect } from "./WeekdaySelect"; | ||||
| import SetTimesModal from "./modal/SetTimesModal"; | ||||
| import Schedule from '../../lib/schedule.model'; | ||||
| import dayjs, {Dayjs} from "dayjs"; | ||||
| import utc from 'dayjs/plugin/utc'; | ||||
| import timezone from 'dayjs/plugin/timezone'; | ||||
| import Schedule from "../../lib/schedule.model"; | ||||
| import dayjs from "dayjs"; | ||||
| import utc from "dayjs/plugin/utc"; | ||||
| import timezone from "dayjs/plugin/timezone"; | ||||
| dayjs.extend(utc); | ||||
| dayjs.extend(timezone); | ||||
| 
 | ||||
| export const Scheduler = (props) => { | ||||
| 
 | ||||
|   const [ schedules, setSchedules ]: Schedule[] = useState(props.schedules.map( schedule => { | ||||
|     const startDate = schedule.isOverride ? dayjs(schedule.startDate) : dayjs.utc().startOf('day').add(schedule.startTime, 'minutes') | ||||
|     return ( | ||||
|       { | ||||
|   const [schedules, setSchedules]: Schedule[] = useState( | ||||
|     props.schedules.map((schedule) => { | ||||
|       const startDate = schedule.isOverride | ||||
|         ? dayjs(schedule.startDate) | ||||
|         : dayjs.utc().startOf("day").add(schedule.startTime, "minutes").tz(props.timeZone); | ||||
|       return { | ||||
|         days: schedule.days, | ||||
|         startDate, | ||||
|         endDate: startDate.add(schedule.length, 'minutes') | ||||
|       } | ||||
|     ) | ||||
|   })); | ||||
|         endDate: startDate.add(schedule.length, "minutes"), | ||||
|       }; | ||||
|     }) | ||||
|   ); | ||||
| 
 | ||||
|   const [ timeZone, setTimeZone ] = useState(props.timeZone); | ||||
|   const [ editSchedule, setEditSchedule ] = useState(-1); | ||||
|   const [timeZone, setTimeZone] = useState(props.timeZone); | ||||
|   const [editSchedule, setEditSchedule] = useState(-1); | ||||
| 
 | ||||
|   useEffect( () => { | ||||
|   useEffect(() => { | ||||
|     props.onChange(schedules); | ||||
|   }, [schedules]) | ||||
|   }, [schedules]); | ||||
| 
 | ||||
|   const addNewSchedule = () => setEditSchedule(schedules.length); | ||||
| 
 | ||||
|   const applyEditSchedule = (changed: Schedule) => { | ||||
|     const replaceWith = { | ||||
|       ...schedules[editSchedule], | ||||
|       ...changed | ||||
|       ...changed, | ||||
|     }; | ||||
| 
 | ||||
|     schedules.splice(editSchedule, 1, replaceWith); | ||||
| 
 | ||||
|     setSchedules([].concat(schedules)); | ||||
|   } | ||||
|   }; | ||||
| 
 | ||||
|   const removeScheduleAt = (toRemove: number) => { | ||||
|     schedules.splice(toRemove, 1); | ||||
|  | @ -49,7 +52,7 @@ export const Scheduler = (props) => { | |||
|   const setWeekdays = (idx: number, days: number[]) => { | ||||
|     schedules[idx].days = days; | ||||
|     setSchedules([].concat(schedules)); | ||||
|   } | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <div> | ||||
|  | @ -60,26 +63,40 @@ export const Scheduler = (props) => { | |||
|               Timezone | ||||
|             </label> | ||||
|             <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> | ||||
|           <ul> | ||||
|             {schedules.map( (schedule, idx) => | ||||
|             {schedules.map((schedule, idx) => ( | ||||
|               <li key={idx} className="py-2 flex justify-between border-t"> | ||||
|               <div className="inline-flex ml-2"> | ||||
|                 <WeekdaySelect defaultValue={schedules[idx].days} onSelect={(days: number[]) => setWeekdays(idx, days)} /> | ||||
|                 <button className="ml-2 text-sm px-2" type="button" onClick={() => setEditSchedule(idx)}> | ||||
|                   {schedule.startDate.format(schedule.startDate.minute() === 0 ? 'ha' : 'h:mma')} until {schedule.endDate.format(schedule.endDate.minute() === 0 ? 'ha' : 'h:mma')} | ||||
|                 <div className="inline-flex ml-2"> | ||||
|                   <WeekdaySelect | ||||
|                     defaultValue={schedules[idx].days} | ||||
|                     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> | ||||
|               </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> | ||||
|             </li>)} | ||||
|               </li> | ||||
|             ))} | ||||
|           </ul> | ||||
|           <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 className="border-l p-2 w-2/5 text-sm bg-gray-50"> | ||||
|           {/*<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>*/} | ||||
|         </div> | ||||
|       </div> | ||||
|       {editSchedule >= 0 && | ||||
|         <SetTimesModal schedule={schedules[editSchedule]} | ||||
|                        onChange={applyEditSchedule} | ||||
|                        onExit={() => setEditSchedule(-1)} /> | ||||
|       } | ||||
|       {editSchedule >= 0 && ( | ||||
|         <SetTimesModal | ||||
|           schedule={schedules[editSchedule]} | ||||
|           onChange={applyEditSchedule} | ||||
|           onExit={() => setEditSchedule(-1)} | ||||
|         /> | ||||
|       )} | ||||
|       {/*{showDateOverrideModal && | ||||
|         <DateOverrideModal /> | ||||
|       }*/} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| }; | ||||
|  |  | |||
|  | @ -1,42 +1,53 @@ | |||
| import React, {useEffect, useState} from "react"; | ||||
| import React, { useEffect, useState } from "react"; | ||||
| 
 | ||||
| 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( () => { | ||||
|     props.onSelect(activeDays.map( (isActive, idx) => isActive ? idx + 1 : 0).filter( (v) => 0 !== v )); | ||||
|   useEffect(() => { | ||||
|     props.onSelect(activeDays.map((v, idx) => (v ? idx : -1)).filter((v) => v !== -1)); | ||||
|   }, [activeDays]); | ||||
| 
 | ||||
|   const toggleDay = (e, idx: number) => { | ||||
|     e.preventDefault(); | ||||
|     activeDays[idx] = !activeDays[idx]; | ||||
|     setActiveDays([].concat(activeDays)); | ||||
|   } | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="weekdaySelect"> | ||||
|       <div className="inline-flex"> | ||||
|         {days.map( (day, idx) => activeDays[idx] ? | ||||
|           <button key={idx} onClick={(e) => toggleDay(e, idx)} | ||||
|                   style={ {"marginLeft": "-2px"} } | ||||
|                   className={` | ||||
|         {days.map((day, idx) => | ||||
|           activeDays[idx] ? ( | ||||
|             <button | ||||
|               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  | ||||
|                     ${activeDays[idx+1] ? 'rounded-r-none': ''}  | ||||
|                     ${activeDays[idx-1] ? 'rounded-l-none': ''}  | ||||
|                     ${idx === 0 ? 'rounded-l' : ''}  | ||||
|                     ${idx === days.length-1 ? 'rounded-r' : ''} | ||||
|                     ${activeDays[idx + 1] ? "rounded-r-none" : ""}  | ||||
|                     ${activeDays[idx - 1] ? "rounded-l-none" : ""}  | ||||
|                     ${idx === 0 ? "rounded-l" : ""}  | ||||
|                     ${idx === days.length - 1 ? "rounded-r" : ""} | ||||
|                   `}>
 | ||||
|             {day} | ||||
|           </button> | ||||
|           : | ||||
|           <button key={idx} onClick={(e) => toggleDay(e, idx)} | ||||
|                   style={ {"marginTop": "1px", "marginBottom": "1px"} } | ||||
|                   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> | ||||
|               {day} | ||||
|             </button> | ||||
|           ) : ( | ||||
|             <button | ||||
|               key={idx} | ||||
|               onClick={(e) => toggleDay(e, idx)} | ||||
|               style={{ marginTop: "1px", marginBottom: "1px" }} | ||||
|               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> | ||||
|   ); | ||||
| }; | ||||
|  |  | |||
|  | @ -1,14 +1,20 @@ | |||
| import {ClockIcon} from "@heroicons/react/outline"; | ||||
| import {useRef} from "react"; | ||||
| import { ClockIcon } from "@heroicons/react/outline"; | ||||
| import { useRef } from "react"; | ||||
| 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) { | ||||
| 
 | ||||
|   const {startDate, endDate} = props.schedule || { | ||||
|     startDate: dayjs().startOf('day').add(540, 'minutes'), | ||||
|     endDate: dayjs().startOf('day').add(1020, 'minutes'), | ||||
|   const { startDate, endDate } = props.schedule || { | ||||
|     startDate: dayjs.utc().startOf("day").add(540, "minutes"), | ||||
|     endDate: dayjs.utc().startOf("day").add(1020, "minutes"), | ||||
|   }; | ||||
| 
 | ||||
|   startDate.tz(props.timeZone); | ||||
|   endDate.tz(props.timeZone); | ||||
| 
 | ||||
|   const startHoursRef = useRef<HTMLInputElement>(); | ||||
|   const startMinsRef = useRef<HTMLInputElement>(); | ||||
|   const endHoursRef = useRef<HTMLInputElement>(); | ||||
|  | @ -31,60 +37,108 @@ export default function SetTimesModal(props) { | |||
|   } | ||||
| 
 | ||||
|   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="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 | ||||
|           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="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="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"/> | ||||
|             <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"> | ||||
|               <ClockIcon className="h-6 w-6 text-blue-600" /> | ||||
|             </div> | ||||
|             <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"> | ||||
|                 Change when you are available for bookings | ||||
|               </h3> | ||||
|               <div> | ||||
|                 <p className="text-sm text-gray-500"> | ||||
|                   Set your work schedule | ||||
|                 </p> | ||||
|                 <p className="text-sm text-gray-500">Set your work schedule</p> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div className="flex mb-4"> | ||||
|             <label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">Start time</label> | ||||
|             <div> | ||||
|               <label htmlFor="startHours" className="sr-only">Hours</label> | ||||
|               <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')} /> | ||||
|               <label htmlFor="startHours" className="sr-only"> | ||||
|                 Hours | ||||
|               </label> | ||||
|               <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> | ||||
|             <span className="mx-2 pt-1">:</span> | ||||
|             <div> | ||||
|               <label htmlFor="startMinutes" className="sr-only">Minutes</label> | ||||
|               <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')} /> | ||||
|               <label htmlFor="startMinutes" className="sr-only"> | ||||
|                 Minutes | ||||
|               </label> | ||||
|               <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 className="flex"> | ||||
|             <label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">End time</label> | ||||
|             <div> | ||||
|               <label htmlFor="endHours" className="sr-only">Hours</label> | ||||
|               <input ref={endHoursRef} type="number" min="0" max="23" 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')} /> | ||||
|               <label htmlFor="endHours" className="sr-only"> | ||||
|                 Hours | ||||
|               </label> | ||||
|               <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> | ||||
|             <span className="mx-2 pt-1">:</span> | ||||
|             <div> | ||||
|               <label htmlFor="endMinutes" className="sr-only">Minutes</label> | ||||
|               <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')} /> | ||||
|               <label htmlFor="endMinutes" className="sr-only"> | ||||
|                 Minutes | ||||
|               </label> | ||||
|               <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 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> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										174
									
								
								lib/slots.ts
									
									
									
									
									
								
							
							
						
						
									
										174
									
								
								lib/slots.ts
									
									
									
									
									
								
							|  | @ -1,94 +1,108 @@ | |||
| const dayjs = require("dayjs"); | ||||
| 
 | ||||
| const isToday = require("dayjs/plugin/isToday"); | ||||
| const utc = require("dayjs/plugin/utc"); | ||||
| const timezone = require("dayjs/plugin/timezone"); | ||||
| 
 | ||||
| dayjs.extend(isToday); | ||||
| import dayjs, { Dayjs } from "dayjs"; | ||||
| import utc from "dayjs/plugin/utc"; | ||||
| dayjs.extend(utc); | ||||
| dayjs.extend(timezone); | ||||
| 
 | ||||
| const getMinutesFromMidnight = (date) => { | ||||
|   return date.hour() * 60 + date.minute(); | ||||
| interface GetSlotsType { | ||||
|   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 = ({ | ||||
|   calendarTimeZone, | ||||
|   eventLength, | ||||
|   selectedTimeZone, | ||||
|   selectedDate, | ||||
|   dayStartTime, | ||||
|   dayEndTime | ||||
| }) => { | ||||
| // say invitee is -60,1380, and boundary is -120,240 - the overlap is -60,240
 | ||||
| const getOverlaps = (inviteeBoundary: Boundary, boundaries: Boundary[]) => | ||||
|   boundaries.map((boundary) => intersectBoundary(inviteeBoundary, boundary)).filter(Boolean); | ||||
| 
 | ||||
|   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
 | ||||
|   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) | ||||
|   const startDay: number = +inviteeDate | ||||
|     .utc() | ||||
|     .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; | ||||
|   if (startDateTime < lowerBound) { | ||||
|     // Getting minutes of the first event in the day of the chooser
 | ||||
|     const diff = lowerBound.diff(startDateTime, "minutes"); | ||||
| 
 | ||||
|     // finding first event
 | ||||
|     phase = diff + eventLength - (diff % eventLength); | ||||
|   } | ||||
| 
 | ||||
|   // We can stop as soon as the selectedTimeZone day ends
 | ||||
|   const endDateTime = upperBound | ||||
|     .tz(calendarTimeZone) | ||||
|     .subtract(eventLength, "minutes"); | ||||
| 
 | ||||
|   const maxMinutes = endDateTime.diff(startDateTime, "minutes"); | ||||
| 
 | ||||
|   const slots = []; | ||||
|   const now = dayjs(); | ||||
|   for ( | ||||
|     let minutes = phase; | ||||
|     minutes <= maxMinutes; | ||||
|     minutes += parseInt(eventLength, 10) | ||||
|   ) { | ||||
|     const slot = startDateTime.add(minutes, "minutes"); | ||||
| 
 | ||||
|     const minutesFromMidnight = getMinutesFromMidnight(slot); | ||||
| 
 | ||||
|     if ( | ||||
|       minutesFromMidnight < dayStartTime || | ||||
|       minutesFromMidnight > dayEndTime - eventLength || | ||||
|       slot < now | ||||
|     ) { | ||||
|       continue; | ||||
|   workingHours.forEach((item) => { | ||||
|     const lowerBound: number = item.startTime; | ||||
|     const upperBound: number = lowerBound + item.length; | ||||
|     if (startDay !== endDay) { | ||||
|       if (inviteeBounds.lowerBound < 0) { | ||||
|         // lowerBound edges into the previous day
 | ||||
|         if (item.days.includes(startDay)) { | ||||
|           boundaries.push({ lowerBound: lowerBound - 1440, upperBound: upperBound - 1440 }); | ||||
|         } | ||||
|         if (item.days.includes(endDay)) { | ||||
|           boundaries.push({ lowerBound, upperBound }); | ||||
|         } | ||||
|       } else { | ||||
|         // upperBound edges into the next day
 | ||||
|         if (item.days.includes(endDay)) { | ||||
|           boundaries.push({ lowerBound: lowerBound + 1440, upperBound: upperBound + 1440 }); | ||||
|         } | ||||
|         if (item.days.includes(startDay)) { | ||||
|           boundaries.push({ lowerBound, upperBound }); | ||||
|         } | ||||
|       } | ||||
|     } else { | ||||
|       boundaries.push({ lowerBound, upperBound }); | ||||
|     } | ||||
|   }); | ||||
|   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; | ||||
| }; | ||||
| 
 | ||||
| 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, | ||||
|   "scripts": { | ||||
|     "dev": "next dev", | ||||
|     "test": "node --experimental-vm-modules node_modules/.bin/jest", | ||||
|     "build": "next build", | ||||
|     "start": "next start", | ||||
|     "postinstall": "prisma generate", | ||||
|  | @ -36,6 +37,7 @@ | |||
|     "uuid": "^8.3.2" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@types/jest": "^26.0.23", | ||||
|     "@types/node": "^14.14.33", | ||||
|     "@types/react": "^17.0.3", | ||||
|     "@typescript-eslint/eslint-plugin": "^4.27.0", | ||||
|  | @ -47,7 +49,9 @@ | |||
|     "eslint-plugin-react": "^7.24.0", | ||||
|     "eslint-plugin-react-hooks": "^4.2.0", | ||||
|     "husky": "^6.0.0", | ||||
|     "jest": "^27.0.5", | ||||
|     "lint-staged": "^11.0.0", | ||||
|     "mockdate": "^3.0.5", | ||||
|     "postcss": "^8.2.8", | ||||
|     "prettier": "^2.3.1", | ||||
|     "prisma": "^2.23.0", | ||||
|  | @ -59,5 +63,19 @@ | |||
|       "prettier --write", | ||||
|       "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 Head from 'next/head'; | ||||
| import Link from 'next/link'; | ||||
| import prisma from '../../lib/prisma'; | ||||
| import dayjs, { Dayjs } from 'dayjs'; | ||||
| import { ClockIcon, GlobeIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid'; | ||||
| import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; | ||||
| import utc from 'dayjs/plugin/utc'; | ||||
| import timezone from 'dayjs/plugin/timezone'; | ||||
| import { useEffect, useState, useMemo } from "react"; | ||||
| import Head from "next/head"; | ||||
| import prisma from "../../lib/prisma"; | ||||
| import dayjs, { Dayjs } from "dayjs"; | ||||
| import { ClockIcon, GlobeIcon, ChevronDownIcon } from "@heroicons/react/solid"; | ||||
| import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; | ||||
| import utc from "dayjs/plugin/utc"; | ||||
| import timezone from "dayjs/plugin/timezone"; | ||||
| dayjs.extend(isSameOrBefore); | ||||
| dayjs.extend(utc); | ||||
| dayjs.extend(timezone); | ||||
| 
 | ||||
| import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry"; | ||||
| import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry"; | ||||
| import AvailableTimes from "../../components/booking/AvailableTimes"; | ||||
| import TimeOptions from "../../components/booking/TimeOptions" | ||||
| import Avatar from '../../components/Avatar'; | ||||
| import {timeZone} from "../../lib/clock"; | ||||
| import TimeOptions from "../../components/booking/TimeOptions"; | ||||
| import Avatar from "../../components/Avatar"; | ||||
| import { timeZone } from "../../lib/clock"; | ||||
| import DatePicker from "../../components/booking/DatePicker"; | ||||
| 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) { | ||||
| 
 | ||||
|   // Get router variables
 | ||||
|   const router = useRouter(); | ||||
|   const { rescheduleUid } = router.query; | ||||
| 
 | ||||
|   // Initialise state
 | ||||
|   const [selectedDate, setSelectedDate] = useState<Dayjs>(); | ||||
|   const [selectedMonth, setSelectedMonth] = useState(dayjs().month()); | ||||
|   const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); | ||||
|   const [timeFormat, setTimeFormat] = useState('h:mma'); | ||||
|   const [timeFormat, setTimeFormat] = useState("h:mma"); | ||||
|   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(() => { | ||||
|     telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters())) | ||||
|   }, []); | ||||
|     telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters())); | ||||
|   }, [telemetry]); | ||||
| 
 | ||||
|   const changeDate = (date: Dayjs) => { | ||||
|     telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters())) | ||||
|     telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters())); | ||||
|     setSelectedDate(date); | ||||
|   }; | ||||
| 
 | ||||
|   const handleSelectTimeZone = (selectedTimeZone: string) => { | ||||
|     if (selectedDate) { | ||||
|       setSelectedDate(selectedDate.tz(selectedTimeZone)) | ||||
|       setSelectedDate(selectedDate.tz(selectedTimeZone)); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const handleToggle24hClock = (is24hClock: boolean) => { | ||||
|     if (selectedDate) { | ||||
|       setTimeFormat(is24hClock ? 'HH:mm' : 'h:mma'); | ||||
|     } | ||||
|   } | ||||
|     setTimeFormat(is24hClock ? "HH:mm" : "h:mma"); | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <div> | ||||
|  | @ -67,40 +73,47 @@ export default function Type(props) { | |||
|         className={ | ||||
|           "mx-auto my-24 transition-max-width ease-in-out duration-500 " + | ||||
|           (selectedDate ? "max-w-6xl" : "max-w-3xl") | ||||
|         } | ||||
|       > | ||||
|         }> | ||||
|         <div className="bg-white shadow rounded-lg"> | ||||
|           <div className="sm:flex px-4 py-5 sm:p-4"> | ||||
|             <div | ||||
|               className={ | ||||
|                 "pr-8 sm:border-r " + (selectedDate ? "sm:w-1/3" : "sm:w-1/2") | ||||
|               } | ||||
|             > | ||||
|             <div 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" /> | ||||
|               <h2 className="font-medium text-gray-500">{props.user.name}</h2> | ||||
|               <h1 className="text-3xl font-semibold text-gray-800 mb-4"> | ||||
|                 {props.eventType.title} | ||||
|               </h1> | ||||
|               <h1 className="text-3xl font-semibold text-gray-800 mb-4">{props.eventType.title}</h1> | ||||
|               <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" /> | ||||
|                 {props.eventType.length} minutes | ||||
|               </p> | ||||
|               <button | ||||
|                 onClick={() => setIsTimeOptionsOpen(true)} | ||||
|                 className="text-gray-500 mb-1 px-2 py-1 -ml-2" | ||||
|               > | ||||
|               <GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" /> | ||||
|               {timeZone()} | ||||
|               <ChevronDownIcon className="inline-block w-4 h-4 ml-1 -mt-1" /> | ||||
|             </button> | ||||
|             { isTimeOptionsOpen && <TimeOptions onSelectTimeZone={handleSelectTimeZone} | ||||
|                                                 onToggle24hClock={handleToggle24hClock} />} | ||||
|             <p className="text-gray-600 mt-3 mb-8"> | ||||
|               {props.eventType.description} | ||||
|             </p> | ||||
|           </div> | ||||
|             <DatePicker weekStart={props.user.weekStart} onDatePicked={changeDate} /> | ||||
|             {selectedDate && <AvailableTimes timeFormat={timeFormat} user={props.user} eventType={props.eventType} date={selectedDate} />} | ||||
|                 className="text-gray-500 mb-1 px-2 py-1 -ml-2"> | ||||
|                 <GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" /> | ||||
|                 {timeZone()} | ||||
|                 <ChevronDownIcon className="inline-block w-4 h-4 ml-1 -mt-1" /> | ||||
|               </button> | ||||
|               {isTimeOptionsOpen && ( | ||||
|                 <TimeOptions | ||||
|                   onSelectTimeZone={handleSelectTimeZone} | ||||
|                   onToggle24hClock={handleToggle24hClock} | ||||
|                 /> | ||||
|               )} | ||||
|               <p className="text-gray-600 mt-3 mb-8">{props.eventType.description}</p> | ||||
|             </div> | ||||
|             <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> | ||||
|         {/* 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) { | ||||
|   const user = await prisma.user.findFirst({ | ||||
|     where: { | ||||
|  | @ -129,13 +150,14 @@ export async function getServerSideProps(context) { | |||
|       timeZone: true, | ||||
|       endTime: true, | ||||
|       weekStart: true, | ||||
|     } | ||||
|       availability: true, | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
|   if (!user) { | ||||
|     return { | ||||
|       notFound: true, | ||||
|     } | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   const eventType = await prisma.eventType.findFirst({ | ||||
|  | @ -149,20 +171,38 @@ export async function getServerSideProps(context) { | |||
|       id: true, | ||||
|       title: true, | ||||
|       description: true, | ||||
|       length: true | ||||
|     } | ||||
|       length: true, | ||||
|       availability: true, | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
|   if (!eventType) { | ||||
|     return { | ||||
|       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 { | ||||
|     props: { | ||||
|       user, | ||||
|       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", | ||||
|       "esnext" | ||||
|     ], | ||||
|     "baseUrl": ".", | ||||
|     "paths": { | ||||
|       "@components/*": ["components/*"], | ||||
|       "@lib/*": ["lib/*"] | ||||
|     }, | ||||
|     "allowJs": true, | ||||
|     "skipLibCheck": true, | ||||
|     "strict": false, | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue
	
	 Alex van Andel
						Alex van Andel