Bugfix/year change (#1323)

This commit is contained in:
Alex van Andel 2021-12-16 16:20:38 +01:00 committed by GitHub
parent e6f71c81bb
commit a3bd226347
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 132 additions and 110 deletions

View file

@ -1,18 +1,22 @@
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid"; import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
import { PeriodType } from "@prisma/client"; import { EventType, PeriodType } from "@prisma/client";
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
// Then, include dayjs-business-time
import dayjsBusinessTime from "dayjs-business-time"; import dayjsBusinessTime from "dayjs-business-time";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import classNames from "@lib/classNames"; import classNames from "@lib/classNames";
import { timeZone } from "@lib/clock";
import { useLocale } from "@lib/hooks/useLocale"; import { useLocale } from "@lib/hooks/useLocale";
import getSlots from "@lib/slots"; import getSlots from "@lib/slots";
import { WorkingHours } from "@lib/types/schedule"; import { WorkingHours } from "@lib/types/schedule";
import Loader from "@components/Loader";
dayjs.extend(dayjsBusinessTime); dayjs.extend(dayjsBusinessTime);
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone);
type DatePickerProps = { type DatePickerProps = {
weekStart: string; weekStart: string;
@ -20,7 +24,7 @@ type DatePickerProps = {
workingHours: WorkingHours[]; workingHours: WorkingHours[];
eventLength: number; eventLength: number;
date: Dayjs | null; date: Dayjs | null;
periodType: string; periodType: PeriodType;
periodStartDate: Date | null; periodStartDate: Date | null;
periodEndDate: Date | null; periodEndDate: Date | null;
periodDays: number | null; periodDays: number | null;
@ -28,6 +32,43 @@ type DatePickerProps = {
minimumBookingNotice: number; minimumBookingNotice: number;
}; };
function isOutOfBounds(
time: dayjs.ConfigType,
{
periodType,
periodDays,
periodCountCalendarDays,
periodStartDate,
periodEndDate,
}: Pick<
EventType,
"periodType" | "periodDays" | "periodCountCalendarDays" | "periodStartDate" | "periodEndDate"
>
) {
const date = dayjs(time);
switch (periodType) {
case PeriodType.ROLLING: {
const periodRollingEndDay = periodCountCalendarDays
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
dayjs().utcOffset(date.utcOffset()).add(periodDays!, "days").endOf("day")
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
dayjs().utcOffset(date.utcOffset()).addBusinessTime(periodDays!, "days").endOf("day");
return date.endOf("day").isAfter(periodRollingEndDay);
}
case PeriodType.RANGE: {
const periodRangeStartDay = dayjs(periodStartDate).utcOffset(date.utcOffset()).endOf("day");
const periodRangeEndDay = dayjs(periodEndDate).utcOffset(date.utcOffset()).endOf("day");
return date.endOf("day").isBefore(periodRangeStartDay) || date.endOf("day").isAfter(periodRangeEndDay);
}
case PeriodType.UNLIMITED:
default:
return false;
}
}
function DatePicker({ function DatePicker({
weekStart, weekStart,
onDatePicked, onDatePicked,
@ -42,37 +83,21 @@ function DatePicker({
minimumBookingNotice, minimumBookingNotice,
}: DatePickerProps): JSX.Element { }: DatePickerProps): JSX.Element {
const { t } = useLocale(); const { t } = useLocale();
const [days, setDays] = useState<({ disabled: boolean; date: number } | null)[]>([]);
const [selectedMonth, setSelectedMonth] = useState<number>( const [browsingDate, setBrowsingDate] = useState<Dayjs | null>(date);
date
? periodType === PeriodType.RANGE
? dayjs(periodStartDate).utcOffset(date.utcOffset()).month()
: date.month()
: dayjs().month() /* High chance server is going to have the same month */
);
useEffect(() => { useEffect(() => {
if (dayjs().month() !== selectedMonth) { if (!browsingDate || (date && browsingDate.utcOffset() !== date?.utcOffset())) {
setSelectedMonth(dayjs().month()); setBrowsingDate(date || dayjs().tz(timeZone()));
} }
// eslint-disable-next-line react-hooks/exhaustive-deps }, [date, browsingDate]);
}, []);
// Handle month changes const days = useMemo(() => {
const incrementMonth = () => { if (!browsingDate) {
setSelectedMonth((selectedMonth ?? 0) + 1); return [];
}; }
const decrementMonth = () => {
setSelectedMonth((selectedMonth ?? 0) - 1);
};
const inviteeDate = (): Dayjs => (date || dayjs()).month(selectedMonth);
useEffect(() => {
// Create placeholder elements for empty days in first week // Create placeholder elements for empty days in first week
let weekdayOfFirst = inviteeDate().date(1).day(); let weekdayOfFirst = browsingDate.startOf("month").day();
if (weekStart === "Monday") { if (weekStart === "Monday") {
weekdayOfFirst -= 1; weekdayOfFirst -= 1;
if (weekdayOfFirst < 0) weekdayOfFirst = 6; if (weekdayOfFirst < 0) weekdayOfFirst = 6;
@ -81,18 +106,15 @@ function DatePicker({
const days = Array(weekdayOfFirst).fill(null); const days = Array(weekdayOfFirst).fill(null);
const isDisabled = (day: number) => { const isDisabled = (day: number) => {
const date: Dayjs = inviteeDate().date(day); const date = browsingDate.startOf("day").date(day);
switch (periodType) {
case PeriodType.ROLLING: {
if (!periodDays) {
throw new Error("PeriodType rolling requires periodDays");
}
const periodRollingEndDay = periodCountCalendarDays
? dayjs.utc().add(periodDays, "days").endOf("day")
: (dayjs.utc() as Dayjs).addBusinessTime(periodDays, "days").endOf("day");
return ( return (
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) || isOutOfBounds(date, {
date.endOf("day").isAfter(periodRollingEndDay) || periodType,
periodStartDate,
periodEndDate,
periodCountCalendarDays,
periodDays,
}) ||
!getSlots({ !getSlots({
inviteeDate: date, inviteeDate: date,
frequency: eventLength, frequency: eventLength,
@ -100,46 +122,29 @@ function DatePicker({
workingHours, workingHours,
}).length }).length
); );
}
case PeriodType.RANGE: {
const periodRangeStartDay = dayjs(periodStartDate).utc().endOf("day");
const periodRangeEndDay = dayjs(periodEndDate).utc().endOf("day");
return (
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
date.endOf("day").isBefore(periodRangeStartDay) ||
date.endOf("day").isAfter(periodRangeEndDay) ||
!getSlots({
inviteeDate: date,
frequency: eventLength,
minimumBookingNotice,
workingHours,
}).length
);
}
case PeriodType.UNLIMITED:
default:
return (
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
!getSlots({
inviteeDate: date,
frequency: eventLength,
minimumBookingNotice,
workingHours,
}).length
);
}
}; };
const daysInMonth = inviteeDate().daysInMonth(); const daysInMonth = browsingDate.daysInMonth();
for (let i = 1; i <= daysInMonth; i++) { for (let i = 1; i <= daysInMonth; i++) {
days.push({ disabled: isDisabled(i), date: i }); days.push({ disabled: isDisabled(i), date: i });
} }
setDays(days); return days;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedMonth]); }, [browsingDate]);
if (!browsingDate) {
return <Loader />;
}
// Handle month changes
const incrementMonth = () => {
setBrowsingDate(browsingDate?.add(1, "month"));
};
const decrementMonth = () => {
setBrowsingDate(browsingDate?.subtract(1, "month"));
};
return ( return (
<div <div
@ -152,20 +157,18 @@ function DatePicker({
<div className="flex mb-4 text-xl font-light text-gray-600"> <div className="flex mb-4 text-xl font-light text-gray-600">
<span className="w-1/2 text-gray-600 dark:text-white"> <span className="w-1/2 text-gray-600 dark:text-white">
<strong className="text-gray-900 dark:text-white"> <strong className="text-gray-900 dark:text-white">
{t(inviteeDate().format("MMMM").toLowerCase())} {t(browsingDate.format("MMMM").toLowerCase())}
</strong>{" "} </strong>{" "}
<span className="text-gray-500">{inviteeDate().format("YYYY")}</span> <span className="text-gray-500">{browsingDate.format("YYYY")}</span>
</span> </span>
<div className="w-1/2 text-right text-gray-600 dark:text-gray-400"> <div className="w-1/2 text-right text-gray-600 dark:text-gray-400">
<button <button
onClick={decrementMonth} onClick={decrementMonth}
className={classNames( className={classNames(
"group mr-2 p-1", "group mr-2 p-1",
typeof selectedMonth === "number" && browsingDate.startOf("month").isBefore(dayjs()) && "text-gray-400 dark:text-gray-600"
selectedMonth <= dayjs().month() &&
"text-gray-400 dark:text-gray-600"
)} )}
disabled={typeof selectedMonth === "number" && selectedMonth <= dayjs().month()} disabled={browsingDate.startOf("month").isBefore(dayjs())}
data-testid="decrementMonth"> data-testid="decrementMonth">
<ChevronLeftIcon className="w-5 h-5 group-hover:text-black dark:group-hover:text-white" /> <ChevronLeftIcon className="w-5 h-5 group-hover:text-black dark:group-hover:text-white" />
</button> </button>
@ -195,13 +198,13 @@ function DatePicker({
<div key={`e-${idx}`} /> <div key={`e-${idx}`} />
) : ( ) : (
<button <button
onClick={() => onDatePicked(inviteeDate().date(day.date))} onClick={() => onDatePicked(browsingDate.date(day.date))}
disabled={day.disabled} disabled={day.disabled}
className={classNames( className={classNames(
"absolute w-full top-0 left-0 right-0 bottom-0 rounded-sm text-center mx-auto", "absolute w-full top-0 left-0 right-0 bottom-0 rounded-sm text-center mx-auto",
"hover:border hover:border-brand dark:hover:border-white", "hover:border hover:border-brand dark:hover:border-white",
day.disabled ? "text-gray-400 font-light hover:border-0 cursor-default" : "font-medium", day.disabled ? "text-gray-400 font-light hover:border-0 cursor-default" : "font-medium",
date && date.isSame(inviteeDate().date(day.date), "day") date && date.isSame(browsingDate.date(day.date), "day")
? "bg-brand text-brandcontrast" ? "bg-brand text-brandcontrast"
: !day.disabled : !day.disabled
? " bg-gray-100 dark:bg-gray-600 dark:text-white" ? " bg-gray-100 dark:bg-gray-600 dark:text-white"

View file

@ -79,7 +79,7 @@ export function getWorkingHours(
]; ];
} }
const utcOffset = relativeTimeUnit.utcOffset || dayjs().tz(relativeTimeUnit.timeZone).utcOffset(); const utcOffset = relativeTimeUnit.utcOffset ?? dayjs().tz(relativeTimeUnit.timeZone).utcOffset();
const workingHours = availability.reduce((workingHours: WorkingHours[], schedule) => { const workingHours = availability.reduce((workingHours: WorkingHours[], schedule) => {
// Get times localised to the given utcOffset/timeZone // Get times localised to the given utcOffset/timeZone

View file

@ -107,7 +107,6 @@ export const useSlots = (props: UseSlotsProps) => {
workingHours: responseBody.workingHours, workingHours: responseBody.workingHours,
minimumBookingNotice, minimumBookingNotice,
}); });
// Check for conflicts // Check for conflicts
for (let i = times.length - 1; i >= 0; i -= 1) { for (let i = times.length - 1; i >= 0; i -= 1) {
responseBody.busy.every((busyTime): boolean => { responseBody.busy.every((busyTime): boolean => {

View file

@ -19,7 +19,7 @@ export type GetSlots = {
const getMinuteOffset = (date: Dayjs, step: number) => { const getMinuteOffset = (date: Dayjs, step: number) => {
// Diffs the current time with the given date and iff same day; (handled by 1440) - return difference; otherwise 0 // Diffs the current time with the given date and iff same day; (handled by 1440) - return difference; otherwise 0
const minuteOffset = Math.min(date.diff(dayjs().startOf("day"), "minute"), 1440) % 1440; const minuteOffset = Math.min(date.diff(dayjs.utc().startOf("day"), "minute"), 1440) % 1440;
// round down to nearest step // round down to nearest step
return Math.ceil(minuteOffset / step) * step; return Math.ceil(minuteOffset / step) * step;
}; };
@ -27,15 +27,11 @@ const getMinuteOffset = (date: Dayjs, step: number) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours }: GetSlots) => { const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours }: GetSlots) => {
// current date in invitee tz // current date in invitee tz
let startDate = dayjs(inviteeDate); // .add(minimumBookingNotice, "minute"); const startDate = dayjs().add(minimumBookingNotice, "minute");
// checks if the start date is in the past // checks if the start date is in the past
if (startDate.isBefore(dayjs(), "day")) { if (inviteeDate.isBefore(startDate, "day")) {
return []; return [];
} }
// Add the current time to the startDate if the day is today
if (startDate.isToday()) {
startDate = startDate.add(dayjs().diff(startDate, "minute"), "minute");
}
const localWorkingHours = getWorkingHours( const localWorkingHours = getWorkingHours(
{ utcOffset: -inviteeDate.utcOffset() }, { utcOffset: -inviteeDate.utcOffset() },
@ -47,14 +43,18 @@ const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours }
).filter((hours) => hours.days.includes(inviteeDate.day())); ).filter((hours) => hours.days.includes(inviteeDate.day()));
const slots: Dayjs[] = []; const slots: Dayjs[] = [];
for (let minutes = getMinuteOffset(startDate, frequency); minutes < 1440; minutes += frequency) { for (let minutes = getMinuteOffset(inviteeDate, frequency); minutes < 1440; minutes += frequency) {
const slot = startDate.startOf("day").add(minutes, "minute"); const slot = dayjs(inviteeDate).startOf("day").add(minutes, "minute");
// check if slot happened already
if (slot.isBefore(startDate)) {
continue;
}
// add slots to available slots if it is found to be between the start and end time of the checked working hours. // add slots to available slots if it is found to be between the start and end time of the checked working hours.
if ( if (
localWorkingHours.some((hours) => localWorkingHours.some((hours) =>
slot.isBetween( slot.isBetween(
startDate.startOf("day").add(hours.startTime, "minute"), inviteeDate.startOf("day").add(hours.startTime, "minute"),
startDate.startOf("day").add(hours.endTime, "minute"), inviteeDate.startOf("day").add(hours.endTime, "minute"),
null, null,
"[)" "[)"
) )

View file

@ -121,8 +121,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
periodStartDate: req.body.periodStartDate, periodStartDate: req.body.periodStartDate,
periodEndDate: req.body.periodEndDate, periodEndDate: req.body.periodEndDate,
periodCountCalendarDays: req.body.periodCountCalendarDays, periodCountCalendarDays: req.body.periodCountCalendarDays,
minimumBookingNotice: req.body.minimumBookingNotice minimumBookingNotice:
? parseInt(req.body.minimumBookingNotice) req.body.minimumBookingNotice || req.body.minimumBookingNotice === 0
? parseInt(req.body.minimumBookingNotice, 10)
: undefined, : undefined,
price: req.body.price, price: req.body.price,
currency: req.body.currency, currency: req.body.currency,

View file

@ -16,7 +16,7 @@ it("can fit 24 hourly slots for an empty day", async () => {
// 24h in a day. // 24h in a day.
expect( expect(
getSlots({ getSlots({
inviteeDate: dayjs().add(1, "day"), inviteeDate: dayjs.utc().add(1, "day").startOf("day"),
frequency: 60, frequency: 60,
minimumBookingNotice: 0, minimumBookingNotice: 0,
workingHours: [ workingHours: [
@ -30,11 +30,12 @@ it("can fit 24 hourly slots for an empty day", async () => {
).toHaveLength(24); ).toHaveLength(24);
}); });
it.skip("only shows future booking slots on the same day", async () => { // TODO: This test is sound; it should pass!
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. // The mock date is 1s to midday, so 12 slots should be open given 0 booking notice.
expect( expect(
getSlots({ getSlots({
inviteeDate: dayjs(), inviteeDate: dayjs.utc(),
frequency: 60, frequency: 60,
minimumBookingNotice: 0, minimumBookingNotice: 0,
workingHours: [ workingHours: [
@ -65,7 +66,7 @@ it("can cut off dates that due to invitee timezone differences fall on the next
).toHaveLength(0); ).toHaveLength(0);
}); });
it.skip("can cut off dates that due to invitee timezone differences fall on the previous day", async () => { it("can cut off dates that due to invitee timezone differences fall on the previous day", async () => {
const workingHours = [ const workingHours = [
{ {
days: [0], days: [0],
@ -75,10 +76,28 @@ it.skip("can cut off dates that due to invitee timezone differences fall on the
]; ];
expect( expect(
getSlots({ getSlots({
inviteeDate: dayjs().startOf("day"), // time translation -01:00 inviteeDate: dayjs().tz("Atlantic/Cape_Verde").startOf("day"), // time translation -01:00
frequency: 60, frequency: 60,
minimumBookingNotice: 0, minimumBookingNotice: 0,
workingHours, workingHours,
}) })
).toHaveLength(0); ).toHaveLength(0);
}); });
it("adds minimum booking notice correctly", async () => {
// 24h in a day.
expect(
getSlots({
inviteeDate: dayjs.utc().add(1, "day").startOf("day"),
frequency: 60,
minimumBookingNotice: 1500,
workingHours: [
{
days: Array.from(Array(7).keys()),
startTime: MINUTES_DAY_START,
endTime: MINUTES_DAY_END,
},
],
})
).toHaveLength(11);
});