Implements slot logic with the DatePicker, more tests for slots
parent
0da99f0d07
commit
e78a34e2ce
|
@ -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,15 +29,36 @@ const DatePicker = ({ weekStart, onDatePicked, workingHours, disableToday }) =>
|
|||
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
|
||||
const daysInMonth = dayjs().month(selectedMonth).daysInMonth();
|
||||
const daysInMonth = inviteeDate.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();
|
||||
let weekdayOfFirst = inviteeDate.date(1).day();
|
||||
if (weekStart === "Monday") {
|
||||
weekdayOfFirst -= 1;
|
||||
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
|
||||
|
@ -43,29 +71,18 @@ const DatePicker = ({ weekStart, onDatePicked, workingHours, disableToday }) =>
|
|||
</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 = [
|
||||
setCalendar([
|
||||
...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)
|
||||
}
|
||||
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(dayjs().month(selectedMonth).date(day), "day")
|
||||
(selectedDate && selectedDate.isSame(inviteeDate.date(day), "day")
|
||||
? " bg-blue-600 text-white-important"
|
||||
: !isDisabled(day)
|
||||
? " bg-blue-50"
|
||||
|
@ -74,17 +91,18 @@ const DatePicker = ({ weekStart, onDatePicked, workingHours, disableToday }) =>
|
|||
{day}
|
||||
</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="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;
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
import { Switch } from "@headlessui/react";
|
||||
import TimezoneSelect from "react-timezone-select";
|
||||
import { useEffect, useState } from "react";
|
||||
import {timeZone, is24h} from '../../lib/clock';
|
||||
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(() => {
|
||||
|
@ -18,22 +17,22 @@ const TimeOptions = (props) => {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTimeZone && timeZone() && selectedTimeZone !== timeZone()) {
|
||||
props.onSelectTimeZone(timeZone(selectedTimeZone));
|
||||
}
|
||||
}, [selectedTimeZone]);
|
||||
|
||||
useEffect(() => {
|
||||
props.onToggle24hClock(is24h(is24hClock));
|
||||
}, [is24hClock]);
|
||||
|
||||
return selectedTimeZone !== "" && (
|
||||
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.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>
|
||||
|
@ -43,8 +42,7 @@ const TimeOptions = (props) => {
|
|||
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"
|
||||
|
@ -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"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default TimeOptions;
|
|
@ -92,8 +92,6 @@ export const Scheduler = ({
|
|||
</li>
|
||||
);
|
||||
|
||||
console.log(selectedTimeZone);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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(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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
@ -20,3 +20,37 @@ 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);
|
||||
});
|
Loading…
Reference in New Issue