From e78a34e2ced08aae1f34ef4e112a39afbc4bead6 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Wed, 30 Jun 2021 01:35:08 +0000 Subject: [PATCH] Implements slot logic with the DatePicker, more tests for slots --- components/booking/DatePicker.tsx | 138 ++++++++++++++++------------- components/booking/TimeOptions.tsx | 101 +++++++++++---------- components/ui/Scheduler.tsx | 2 - lib/slots.ts | 10 ++- pages/[user]/[type].tsx | 24 ++--- test/lib/slots.test.ts | 36 +++++++- 6 files changed, 177 insertions(+), 134 deletions(-) diff --git a/components/booking/DatePicker.tsx b/components/booking/DatePicker.tsx index ac1a0420cf..0e02940c51 100644 --- a/components/booking/DatePicker.tsx +++ b/components/booking/DatePicker.tsx @@ -1,13 +1,20 @@ 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); +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; +import getSlots from "@lib/slots"; +dayjs.extend(utc); +dayjs.extend(timezone); -const DatePicker = ({ weekStart, onDatePicked, workingHours, disableToday }) => { - const workingDays = workingHours.reduce((workingDays: number[], wh) => [...workingDays, ...wh.days], []); - const [selectedMonth, setSelectedMonth] = useState(dayjs().month()); - const [selectedDate, setSelectedDate] = useState(); +const DatePicker = ({ weekStart, onDatePicked, workingHours, organizerTimeZone, inviteeTimeZone }) => { + const [calendar, setCalendar] = useState([]); + const [selectedMonth, setSelectedMonth]: number = useState(); + const [selectedDate, setSelectedDate]: Dayjs = useState(); + + useEffect(() => { + setSelectedMonth(dayjs().tz(inviteeTimeZone).month()); + }, []); useEffect(() => { if (selectedDate) onDatePicked(selectedDate); @@ -22,69 +29,80 @@ const DatePicker = ({ weekStart, onDatePicked, workingHours, disableToday }) => setSelectedMonth(selectedMonth - 1); }; - // Set up calendar - const daysInMonth = dayjs().month(selectedMonth).daysInMonth(); - const days = []; - for (let i = 1; i <= daysInMonth; i++) { - days.push(i); - } + useEffect(() => { + if (!selectedMonth) { + // wish next had a way of dealing with this magically; + return; + } - // Create placeholder elements for empty days in first week - let weekdayOfFirst = dayjs().month(selectedMonth).date(1).day(); - if (weekStart === "Monday") { - weekdayOfFirst -= 1; - if (weekdayOfFirst < 0) weekdayOfFirst = 6; - } - const emptyDays = Array(weekdayOfFirst) - .fill(null) - .map((day, i) => ( -
- {null} -
- )); + const inviteeDate = dayjs().tz(inviteeTimeZone).month(selectedMonth); - const isDisabled = (day: number) => { - const date: Dayjs = dayjs().month(selectedMonth).date(day); - return ( - date.isBefore(dayjs()) || !workingDays.includes(+date.format("d")) || (date.isToday() && disableToday) - ); - }; + const isDisabled = (day: number) => { + const date: Dayjs = inviteeDate.date(day); + return ( + date.endOf("day").isBefore(dayjs().tz(inviteeTimeZone)) || + !getSlots({ + inviteeDate: date, + frequency: 30, + workingHours, + organizerTimeZone, + }).length + ); + }; - // Combine placeholder days with actual days - const calendar = [ - ...emptyDays, - ...days.map((day) => ( - - )), - ]; + // Set up calendar + const daysInMonth = inviteeDate.daysInMonth(); + const days = []; + for (let i = 1; i <= daysInMonth; i++) { + days.push(i); + } - return ( + // Create placeholder elements for empty days in first week + let weekdayOfFirst = inviteeDate.date(1).day(); + if (weekStart === "Monday") { + weekdayOfFirst -= 1; + if (weekdayOfFirst < 0) weekdayOfFirst = 6; + } + const emptyDays = Array(weekdayOfFirst) + .fill(null) + .map((day, i) => ( +
+ {null} +
+ )); + + // Combine placeholder days with actual days + setCalendar([ + ...emptyDays, + ...days.map((day) => ( + + )), + ]); + }, [selectedMonth, inviteeTimeZone]); + + return selectedMonth ? (
{dayjs().month(selectedMonth).format("MMMM YYYY")}
- ); + ) : null; }; export default DatePicker; diff --git a/components/booking/TimeOptions.tsx b/components/booking/TimeOptions.tsx index 38aafd8bf5..580e174e4b 100644 --- a/components/booking/TimeOptions.tsx +++ b/components/booking/TimeOptions.tsx @@ -1,73 +1,72 @@ -import {Switch} from "@headlessui/react"; +import { Switch } from "@headlessui/react"; import TimezoneSelect from "react-timezone-select"; -import {useEffect, useState} from "react"; -import {timeZone, is24h} from '../../lib/clock'; +import { useEffect, useState } from "react"; +import { timeZone, is24h } from "../../lib/clock"; function classNames(...classes) { - return classes.filter(Boolean).join(' ') + return classes.filter(Boolean).join(" "); } const TimeOptions = (props) => { - - const [selectedTimeZone, setSelectedTimeZone] = useState(''); + const [selectedTimeZone, setSelectedTimeZone] = useState(""); const [is24hClock, setIs24hClock] = useState(false); - useEffect( () => { + useEffect(() => { setIs24hClock(is24h()); setSelectedTimeZone(timeZone()); }, []); - useEffect( () => { - props.onSelectTimeZone(timeZone(selectedTimeZone)); + useEffect(() => { + if (selectedTimeZone && timeZone() && selectedTimeZone !== timeZone()) { + props.onSelectTimeZone(timeZone(selectedTimeZone)); + } }, [selectedTimeZone]); - useEffect( () => { + useEffect(() => { props.onToggle24hClock(is24h(is24hClock)); }, [is24hClock]); - return selectedTimeZone !== "" && ( -
-
-
Time Options
-
- - - am/pm - - - Use setting -
- setSelectedTimeZone(tz.value)} - className="mb-2 shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md" - /> -
+ ) ); -} +}; -export default TimeOptions; \ No newline at end of file +export default TimeOptions; diff --git a/components/ui/Scheduler.tsx b/components/ui/Scheduler.tsx index 9f25dcf4cd..045c726d20 100644 --- a/components/ui/Scheduler.tsx +++ b/components/ui/Scheduler.tsx @@ -92,8 +92,6 @@ export const Scheduler = ({ ); - console.log(selectedTimeZone); - return (
diff --git a/lib/slots.ts b/lib/slots.ts index c82913b597..35d06a65a1 100644 --- a/lib/slots.ts +++ b/lib/slots.ts @@ -4,10 +4,16 @@ import timezone from "dayjs/plugin/timezone"; dayjs.extend(utc); dayjs.extend(timezone); +type WorkingHour = { + days: number[]; + startTime: number; + endTime: number; +}; + type GetSlots = { inviteeDate: Dayjs; frequency: number; - workingHours: []; + workingHours: WorkingHour[]; minimumBookingNotice?: number; organizerTimeZone: string; }; @@ -110,7 +116,7 @@ const getSlots = ({ organizerTimeZone, }: GetSlots): Dayjs[] => { const startTime = dayjs.utc().isSame(dayjs(inviteeDate), "day") - ? inviteeDate.hour() * 60 + inviteeDate.minute() + minimumBookingNotice + ? inviteeDate.hour() * 60 + inviteeDate.minute() + (minimumBookingNotice || 0) : 0; const inviteeBounds = inviteeBoundary(startTime, inviteeDate.utcOffset(), frequency); diff --git a/pages/[user]/[type].tsx b/pages/[user]/[type].tsx index 42fba5ecff..1bda7a5098 100644 --- a/pages/[user]/[type].tsx +++ b/pages/[user]/[type].tsx @@ -1,10 +1,10 @@ -import { useEffect, useState, useMemo } from "react"; +import { useEffect, useState } from "react"; import { GetServerSideProps } from "next"; import Head from "next/head"; import { ClockIcon, GlobeIcon, ChevronDownIcon } from "@heroicons/react/solid"; import prisma from "../../lib/prisma"; import { useRouter } from "next/router"; -import dayjs, { Dayjs } from "dayjs"; +import { Dayjs } from "dayjs"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry"; import AvailableTimes from "../../components/booking/AvailableTimes"; @@ -13,7 +13,6 @@ import Avatar from "../../components/Avatar"; import { timeZone } from "../../lib/clock"; import DatePicker from "../../components/booking/DatePicker"; import PoweredByCalendso from "../../components/ui/PoweredByCalendso"; -import getSlots from "@lib/slots"; export default function Type(props): Type { // Get router variables @@ -25,32 +24,20 @@ export default function Type(props): Type { 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, - organizerTimeZone: props.eventType.timeZone, - minimumBookingNotice: 0, - }).length === 0, - [today, props.eventType.length, props.workingHours] - ); - useEffect(() => { telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters())); }, [telemetry]); const changeDate = (date: Dayjs) => { telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters())); - setSelectedDate(date.tz(timeZone())); + setSelectedDate(date); }; const handleSelectTimeZone = (selectedTimeZone: string): void => { if (selectedDate) { setSelectedDate(selectedDate.tz(selectedTimeZone)); } + setIsTimeOptionsOpen(false); }; const handleToggle24hClock = (is24hClock: boolean) => { @@ -136,10 +123,11 @@ export default function Type(props): Type {

{props.eventType.description}

{selectedDate && ( { // 24h in a day. @@ -19,4 +19,38 @@ it('can fit 24 hourly slots for an empty day', async () => { ], organizerTimeZone: 'Europe/London' })).toHaveLength(24); +}); + +it('only shows future booking slots on the same day', async () => { + // The mock date is 1s to midday, so 12 slots should be open given 0 booking notice. + expect(getSlots({ + inviteeDate: dayjs(), + frequency: 60, + workingHours: [ + { days: [...Array(7).keys()], startTime: 0, endTime: 1440 } + ], + organizerTimeZone: 'GMT' + })).toHaveLength(12); +}); + +it('can cut off dates that due to invitee timezone differences fall on the next day', async () => { + expect(getSlots({ + inviteeDate: dayjs().tz('Europe/Amsterdam').startOf('day'), // time translation +01:00 + frequency: 60, + workingHours: [ + { days: [0], startTime: 1380, endTime: 1440 } + ], + organizerTimeZone: 'Europe/London' + })).toHaveLength(0); +}); + +it('can cut off dates that due to invitee timezone differences fall on the previous day', async () => { + expect(getSlots({ + inviteeDate: dayjs().startOf('day'), // time translation -01:00 + frequency: 60, + workingHours: [ + { days: [0], startTime: 0, endTime: 60 } + ], + organizerTimeZone: 'Europe/London' + })).toHaveLength(0); }); \ No newline at end of file