Implements slot logic with the DatePicker, more tests for slots

This commit is contained in:
Alex van Andel 2021-06-30 01:35:08 +00:00
parent 0da99f0d07
commit e78a34e2ce
6 changed files with 177 additions and 134 deletions

View file

@ -1,13 +1,20 @@
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 dayjs, { Dayjs } from "dayjs";
import isToday from "dayjs/plugin/isToday"; import utc from "dayjs/plugin/utc";
dayjs.extend(isToday); import timezone from "dayjs/plugin/timezone";
import getSlots from "@lib/slots";
dayjs.extend(utc);
dayjs.extend(timezone);
const DatePicker = ({ weekStart, onDatePicked, workingHours, disableToday }) => { const DatePicker = ({ weekStart, onDatePicked, workingHours, organizerTimeZone, inviteeTimeZone }) => {
const workingDays = workingHours.reduce((workingDays: number[], wh) => [...workingDays, ...wh.days], []); const [calendar, setCalendar] = useState([]);
const [selectedMonth, setSelectedMonth] = useState(dayjs().month()); const [selectedMonth, setSelectedMonth]: number = useState();
const [selectedDate, setSelectedDate] = useState(); const [selectedDate, setSelectedDate]: Dayjs = useState();
useEffect(() => {
setSelectedMonth(dayjs().tz(inviteeTimeZone).month());
}, []);
useEffect(() => { useEffect(() => {
if (selectedDate) onDatePicked(selectedDate); if (selectedDate) onDatePicked(selectedDate);
@ -22,69 +29,80 @@ const DatePicker = ({ weekStart, onDatePicked, workingHours, disableToday }) =>
setSelectedMonth(selectedMonth - 1); setSelectedMonth(selectedMonth - 1);
}; };
// Set up calendar useEffect(() => {
const daysInMonth = dayjs().month(selectedMonth).daysInMonth(); if (!selectedMonth) {
const days = []; // wish next had a way of dealing with this magically;
for (let i = 1; i <= daysInMonth; i++) { return;
days.push(i); }
}
// Create placeholder elements for empty days in first week const inviteeDate = dayjs().tz(inviteeTimeZone).month(selectedMonth);
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) => (
<div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}>
{null}
</div>
));
const isDisabled = (day: number) => { const isDisabled = (day: number) => {
const date: Dayjs = dayjs().month(selectedMonth).date(day); const date: Dayjs = inviteeDate.date(day);
return ( return (
date.isBefore(dayjs()) || !workingDays.includes(+date.format("d")) || (date.isToday() && disableToday) date.endOf("day").isBefore(dayjs().tz(inviteeTimeZone)) ||
); !getSlots({
}; inviteeDate: date,
frequency: 30,
workingHours,
organizerTimeZone,
}).length
);
};
// Combine placeholder days with actual days // Set up calendar
const calendar = [ const daysInMonth = inviteeDate.daysInMonth();
...emptyDays, const days = [];
...days.map((day) => ( for (let i = 1; i <= daysInMonth; i++) {
<button days.push(i);
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 ( // 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) => (
<div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}>
{null}
</div>
));
// Combine placeholder days with actual days
setCalendar([
...emptyDays,
...days.map((day) => (
<button
key={day}
onClick={() => setSelectedDate(inviteeDate.date(day))}
disabled={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(inviteeDate.date(day), "day")
? " bg-blue-600 text-white-important"
: !isDisabled(day)
? " bg-blue-50"
: "")
}>
{day}
</button>
)),
]);
}, [selectedMonth, inviteeTimeZone]);
return selectedMonth ? (
<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={"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"> <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"> <div className="w-1/2 text-right">
<button <button
onClick={decrementMonth} onClick={decrementMonth}
className={"mr-4 " + (selectedMonth < parseInt(dayjs().format("MM")) && "text-gray-400")} className={"mr-4 " + (selectedMonth <= dayjs().tz(inviteeTimeZone).month() && "text-gray-400")}
disabled={selectedMonth < parseInt(dayjs().format("MM"))}> disabled={selectedMonth <= dayjs().tz(inviteeTimeZone).month()}>
<ChevronLeftIcon className="w-5 h-5" /> <ChevronLeftIcon className="w-5 h-5" />
</button> </button>
<button onClick={incrementMonth}> <button onClick={incrementMonth}>
@ -103,7 +121,7 @@ const DatePicker = ({ weekStart, onDatePicked, workingHours, disableToday }) =>
{calendar} {calendar}
</div> </div>
</div> </div>
); ) : null;
}; };
export default DatePicker; export default DatePicker;

View file

@ -1,73 +1,72 @@
import {Switch} from "@headlessui/react"; import { Switch } from "@headlessui/react";
import TimezoneSelect from "react-timezone-select"; import TimezoneSelect from "react-timezone-select";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import {timeZone, is24h} from '../../lib/clock'; import { timeZone, is24h } from "../../lib/clock";
function classNames(...classes) { function classNames(...classes) {
return classes.filter(Boolean).join(' ') return classes.filter(Boolean).join(" ");
} }
const TimeOptions = (props) => { const TimeOptions = (props) => {
const [selectedTimeZone, setSelectedTimeZone] = useState("");
const [selectedTimeZone, setSelectedTimeZone] = useState('');
const [is24hClock, setIs24hClock] = useState(false); const [is24hClock, setIs24hClock] = useState(false);
useEffect( () => { useEffect(() => {
setIs24hClock(is24h()); setIs24hClock(is24h());
setSelectedTimeZone(timeZone()); setSelectedTimeZone(timeZone());
}, []); }, []);
useEffect( () => { useEffect(() => {
props.onSelectTimeZone(timeZone(selectedTimeZone)); if (selectedTimeZone && timeZone() && selectedTimeZone !== timeZone()) {
props.onSelectTimeZone(timeZone(selectedTimeZone));
}
}, [selectedTimeZone]); }, [selectedTimeZone]);
useEffect( () => { useEffect(() => {
props.onToggle24hClock(is24h(is24hClock)); props.onToggle24hClock(is24h(is24hClock));
}, [is24hClock]); }, [is24hClock]);
return selectedTimeZone !== "" && ( return (
<div className="w-full rounded shadow border bg-white px-4 py-2"> selectedTimeZone !== "" && (
<div className="flex mb-4"> <div className="w-full rounded shadow border bg-white px-4 py-2">
<div className="w-1/2 font-medium">Time Options</div> <div className="flex mb-4">
<div className="w-1/2"> <div className="w-1/2 font-medium">Time Options</div>
<Switch.Group <div className="w-1/2">
as="div" <Switch.Group as="div" className="flex items-center justify-end">
className="flex items-center justify-end" <Switch.Label as="span" className="mr-3">
> <span className="text-sm text-gray-500">am/pm</span>
<Switch.Label as="span" className="mr-3"> </Switch.Label>
<span className="text-sm text-gray-500">am/pm</span> <Switch
</Switch.Label> checked={is24hClock}
<Switch onChange={setIs24hClock}
checked={is24hClock}
onChange={setIs24hClock}
className={classNames(
is24hClock ? "bg-blue-600" : "bg-gray-200",
"relative inline-flex flex-shrink-0 h-5 w-8 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
)}
>
<span className="sr-only">Use setting</span>
<span
aria-hidden="true"
className={classNames( className={classNames(
is24hClock ? "translate-x-3" : "translate-x-0", is24hClock ? "bg-blue-600" : "bg-gray-200",
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200" "relative inline-flex flex-shrink-0 h-5 w-8 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
)} )}>
/> <span className="sr-only">Use setting</span>
</Switch> <span
<Switch.Label as="span" className="ml-3"> aria-hidden="true"
<span className="text-sm text-gray-500">24h</span> className={classNames(
</Switch.Label> is24hClock ? "translate-x-3" : "translate-x-0",
</Switch.Group> "pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
)}
/>
</Switch>
<Switch.Label as="span" className="ml-3">
<span className="text-sm text-gray-500">24h</span>
</Switch.Label>
</Switch.Group>
</div>
</div> </div>
<TimezoneSelect
id="timeZone"
value={selectedTimeZone}
onChange={(tz) => 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"
/>
</div> </div>
<TimezoneSelect )
id="timeZone"
value={selectedTimeZone}
onChange={(tz) => 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"
/>
</div>
); );
} };
export default TimeOptions; export default TimeOptions;

View file

@ -92,8 +92,6 @@ export const Scheduler = ({
</li> </li>
); );
console.log(selectedTimeZone);
return ( return (
<div> <div>
<div className="rounded border flex"> <div className="rounded border flex">

View file

@ -4,10 +4,16 @@ import timezone from "dayjs/plugin/timezone";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
type WorkingHour = {
days: number[];
startTime: number;
endTime: number;
};
type GetSlots = { type GetSlots = {
inviteeDate: Dayjs; inviteeDate: Dayjs;
frequency: number; frequency: number;
workingHours: []; workingHours: WorkingHour[];
minimumBookingNotice?: number; minimumBookingNotice?: number;
organizerTimeZone: string; organizerTimeZone: string;
}; };
@ -110,7 +116,7 @@ const getSlots = ({
organizerTimeZone, organizerTimeZone,
}: GetSlots): Dayjs[] => { }: GetSlots): Dayjs[] => {
const startTime = dayjs.utc().isSame(dayjs(inviteeDate), "day") const startTime = dayjs.utc().isSame(dayjs(inviteeDate), "day")
? inviteeDate.hour() * 60 + inviteeDate.minute() + minimumBookingNotice ? inviteeDate.hour() * 60 + inviteeDate.minute() + (minimumBookingNotice || 0)
: 0; : 0;
const inviteeBounds = inviteeBoundary(startTime, inviteeDate.utcOffset(), frequency); const inviteeBounds = inviteeBoundary(startTime, inviteeDate.utcOffset(), frequency);

View file

@ -1,10 +1,10 @@
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState } from "react";
import { GetServerSideProps } from "next"; import { GetServerSideProps } from "next";
import Head from "next/head"; import Head from "next/head";
import { ClockIcon, GlobeIcon, ChevronDownIcon } from "@heroicons/react/solid"; import { ClockIcon, GlobeIcon, ChevronDownIcon } from "@heroicons/react/solid";
import prisma from "../../lib/prisma"; import prisma from "../../lib/prisma";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import dayjs, { Dayjs } from "dayjs"; import { Dayjs } from "dayjs";
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";
@ -13,7 +13,6 @@ 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 getSlots from "@lib/slots";
export default function Type(props): Type { export default function Type(props): Type {
// Get router variables // Get router variables
@ -25,32 +24,20 @@ export default function Type(props): Type {
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,
organizerTimeZone: props.eventType.timeZone,
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]); }, [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.tz(timeZone())); setSelectedDate(date);
}; };
const handleSelectTimeZone = (selectedTimeZone: string): void => { const handleSelectTimeZone = (selectedTimeZone: string): void => {
if (selectedDate) { if (selectedDate) {
setSelectedDate(selectedDate.tz(selectedTimeZone)); setSelectedDate(selectedDate.tz(selectedTimeZone));
} }
setIsTimeOptionsOpen(false);
}; };
const handleToggle24hClock = (is24hClock: boolean) => { const handleToggle24hClock = (is24hClock: boolean) => {
@ -136,10 +123,11 @@ export default function Type(props): Type {
<p className="text-gray-600 mt-3 mb-8">{props.eventType.description}</p> <p className="text-gray-600 mt-3 mb-8">{props.eventType.description}</p>
</div> </div>
<DatePicker <DatePicker
disableToday={noSlotsToday}
weekStart={props.user.weekStart} weekStart={props.user.weekStart}
onDatePicked={changeDate} onDatePicked={changeDate}
workingHours={props.workingHours} workingHours={props.workingHours}
organizerTimeZone={props.eventType.timeZone || props.user.timeZone}
inviteeTimeZone={timeZone()}
/> />
{selectedDate && ( {selectedDate && (
<AvailableTimes <AvailableTimes

View file

@ -7,7 +7,7 @@ import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
MockDate.set('2021-06-20T12:00:00Z'); MockDate.set('2021-06-20T11:59:59Z');
it('can fit 24 hourly slots for an empty day', async () => { it('can fit 24 hourly slots for an empty day', async () => {
// 24h in a day. // 24h in a day.
@ -20,3 +20,37 @@ it('can fit 24 hourly slots for an empty day', async () => {
organizerTimeZone: 'Europe/London' organizerTimeZone: 'Europe/London'
})).toHaveLength(24); })).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);
});