Implements slot logic with the DatePicker, more tests for slots
This commit is contained in:
parent
0da99f0d07
commit
e78a34e2ce
6 changed files with 177 additions and 134 deletions
|
@ -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,15 +29,36 @@ const DatePicker = ({ weekStart, onDatePicked, workingHours, disableToday }) =>
|
||||||
setSelectedMonth(selectedMonth - 1);
|
setSelectedMonth(selectedMonth - 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedMonth) {
|
||||||
|
// wish next had a way of dealing with this magically;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviteeDate = dayjs().tz(inviteeTimeZone).month(selectedMonth);
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Set up calendar
|
// Set up calendar
|
||||||
const daysInMonth = dayjs().month(selectedMonth).daysInMonth();
|
const daysInMonth = inviteeDate.daysInMonth();
|
||||||
const days = [];
|
const days = [];
|
||||||
for (let i = 1; i <= daysInMonth; i++) {
|
for (let i = 1; i <= daysInMonth; i++) {
|
||||||
days.push(i);
|
days.push(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create placeholder elements for empty days in first week
|
// Create placeholder elements for empty days in first week
|
||||||
let weekdayOfFirst = dayjs().month(selectedMonth).date(1).day();
|
let weekdayOfFirst = inviteeDate.date(1).day();
|
||||||
if (weekStart === "Monday") {
|
if (weekStart === "Monday") {
|
||||||
weekdayOfFirst -= 1;
|
weekdayOfFirst -= 1;
|
||||||
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
|
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
|
||||||
|
@ -43,29 +71,18 @@ const DatePicker = ({ weekStart, onDatePicked, workingHours, disableToday }) =>
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
|
|
||||||
const isDisabled = (day: number) => {
|
|
||||||
const date: Dayjs = dayjs().month(selectedMonth).date(day);
|
|
||||||
return (
|
|
||||||
date.isBefore(dayjs()) || !workingDays.includes(+date.format("d")) || (date.isToday() && disableToday)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Combine placeholder days with actual days
|
// Combine placeholder days with actual days
|
||||||
const calendar = [
|
setCalendar([
|
||||||
...emptyDays,
|
...emptyDays,
|
||||||
...days.map((day) => (
|
...days.map((day) => (
|
||||||
<button
|
<button
|
||||||
key={day}
|
key={day}
|
||||||
onClick={() => setSelectedDate(dayjs().month(selectedMonth).date(day))}
|
onClick={() => setSelectedDate(inviteeDate.date(day))}
|
||||||
disabled={
|
disabled={isDisabled(day)}
|
||||||
(selectedMonth < parseInt(dayjs().format("MM")) &&
|
|
||||||
dayjs().month(selectedMonth).format("D") > day) ||
|
|
||||||
isDisabled(day)
|
|
||||||
}
|
|
||||||
className={
|
className={
|
||||||
"text-center w-10 h-10 rounded-full mx-auto" +
|
"text-center w-10 h-10 rounded-full mx-auto" +
|
||||||
(isDisabled(day) ? " text-gray-400 font-light" : " text-blue-600 font-medium") +
|
(isDisabled(day) ? " text-gray-400 font-light" : " text-blue-600 font-medium") +
|
||||||
(selectedDate && selectedDate.isSame(dayjs().month(selectedMonth).date(day), "day")
|
(selectedDate && selectedDate.isSame(inviteeDate.date(day), "day")
|
||||||
? " bg-blue-600 text-white-important"
|
? " bg-blue-600 text-white-important"
|
||||||
: !isDisabled(day)
|
: !isDisabled(day)
|
||||||
? " bg-blue-50"
|
? " bg-blue-50"
|
||||||
|
@ -74,17 +91,18 @@ const DatePicker = ({ weekStart, onDatePicked, workingHours, disableToday }) =>
|
||||||
{day}
|
{day}
|
||||||
</button>
|
</button>
|
||||||
)),
|
)),
|
||||||
];
|
]);
|
||||||
|
}, [selectedMonth, inviteeTimeZone]);
|
||||||
|
|
||||||
return (
|
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;
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
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(() => {
|
||||||
|
@ -18,22 +17,22 @@ const TimeOptions = (props) => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (selectedTimeZone && timeZone() && selectedTimeZone !== timeZone()) {
|
||||||
props.onSelectTimeZone(timeZone(selectedTimeZone));
|
props.onSelectTimeZone(timeZone(selectedTimeZone));
|
||||||
|
}
|
||||||
}, [selectedTimeZone]);
|
}, [selectedTimeZone]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
props.onToggle24hClock(is24h(is24hClock));
|
props.onToggle24hClock(is24h(is24hClock));
|
||||||
}, [is24hClock]);
|
}, [is24hClock]);
|
||||||
|
|
||||||
return selectedTimeZone !== "" && (
|
return (
|
||||||
|
selectedTimeZone !== "" && (
|
||||||
<div className="w-full rounded shadow border bg-white px-4 py-2">
|
<div className="w-full rounded shadow border bg-white px-4 py-2">
|
||||||
<div className="flex mb-4">
|
<div className="flex mb-4">
|
||||||
<div className="w-1/2 font-medium">Time Options</div>
|
<div className="w-1/2 font-medium">Time Options</div>
|
||||||
<div className="w-1/2">
|
<div className="w-1/2">
|
||||||
<Switch.Group
|
<Switch.Group as="div" className="flex items-center justify-end">
|
||||||
as="div"
|
|
||||||
className="flex items-center justify-end"
|
|
||||||
>
|
|
||||||
<Switch.Label as="span" className="mr-3">
|
<Switch.Label as="span" className="mr-3">
|
||||||
<span className="text-sm text-gray-500">am/pm</span>
|
<span className="text-sm text-gray-500">am/pm</span>
|
||||||
</Switch.Label>
|
</Switch.Label>
|
||||||
|
@ -43,8 +42,7 @@ const TimeOptions = (props) => {
|
||||||
className={classNames(
|
className={classNames(
|
||||||
is24hClock ? "bg-blue-600" : "bg-gray-200",
|
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"
|
"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 className="sr-only">Use setting</span>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
@ -67,7 +65,8 @@ const TimeOptions = (props) => {
|
||||||
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"
|
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>
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default TimeOptions;
|
export default TimeOptions;
|
|
@ -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">
|
||||||
|
|
10
lib/slots.ts
10
lib/slots.ts
|
@ -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);
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
Loading…
Reference in a new issue