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 { 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) => (
<div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}>
{null}
</div>
));
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) => (
<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>
)),
];
// 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) => (
<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="flex text-gray-600 font-light text-xl mb-4 ml-2">
<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 <= dayjs().tz(inviteeTimeZone).month() && "text-gray-400")}
disabled={selectedMonth <= dayjs().tz(inviteeTimeZone).month()}>
<ChevronLeftIcon className="w-5 h-5" />
</button>
<button onClick={incrementMonth}>
@ -103,7 +121,7 @@ const DatePicker = ({ weekStart, onDatePicked, workingHours, disableToday }) =>
{calendar}
</div>
</div>
);
) : null;
};
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 {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 !== "" && (
<div className="w-full rounded shadow border bg-white px-4 py-2">
<div className="flex mb-4">
<div className="w-1/2 font-medium">Time Options</div>
<div className="w-1/2">
<Switch.Group
as="div"
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>
<Switch
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"
return (
selectedTimeZone !== "" && (
<div className="w-full rounded shadow border bg-white px-4 py-2">
<div className="flex mb-4">
<div className="w-1/2 font-medium">Time Options</div>
<div className="w-1/2">
<Switch.Group as="div" 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>
<Switch
checked={is24hClock}
onChange={setIs24hClock}
className={classNames(
is24hClock ? "translate-x-3" : "translate-x-0",
"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>
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(
is24hClock ? "translate-x-3" : "translate-x-0",
"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>
<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>
<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>
);
console.log(selectedTimeZone);
return (
<div>
<div className="rounded border flex">

View File

@ -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);

View File

@ -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 {
<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}
organizerTimeZone={props.eventType.timeZone || props.user.timeZone}
inviteeTimeZone={timeZone()}
/>
{selectedDate && (
<AvailableTimes

View File

@ -7,7 +7,7 @@ import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
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 () => {
// 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);
});