Bugfix/year change (#1323)
parent
e6f71c81bb
commit
a3bd226347
|
@ -1,18 +1,22 @@
|
|||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
|
||||
import { PeriodType } from "@prisma/client";
|
||||
import { EventType, PeriodType } from "@prisma/client";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
// Then, include dayjs-business-time
|
||||
import dayjsBusinessTime from "dayjs-business-time";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import getSlots from "@lib/slots";
|
||||
import { WorkingHours } from "@lib/types/schedule";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
dayjs.extend(dayjsBusinessTime);
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
type DatePickerProps = {
|
||||
weekStart: string;
|
||||
|
@ -20,7 +24,7 @@ type DatePickerProps = {
|
|||
workingHours: WorkingHours[];
|
||||
eventLength: number;
|
||||
date: Dayjs | null;
|
||||
periodType: string;
|
||||
periodType: PeriodType;
|
||||
periodStartDate: Date | null;
|
||||
periodEndDate: Date | null;
|
||||
periodDays: number | null;
|
||||
|
@ -28,6 +32,43 @@ type DatePickerProps = {
|
|||
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({
|
||||
weekStart,
|
||||
onDatePicked,
|
||||
|
@ -42,37 +83,21 @@ function DatePicker({
|
|||
minimumBookingNotice,
|
||||
}: DatePickerProps): JSX.Element {
|
||||
const { t } = useLocale();
|
||||
const [days, setDays] = useState<({ disabled: boolean; date: number } | null)[]>([]);
|
||||
|
||||
const [selectedMonth, setSelectedMonth] = useState<number>(
|
||||
date
|
||||
? periodType === PeriodType.RANGE
|
||||
? dayjs(periodStartDate).utcOffset(date.utcOffset()).month()
|
||||
: date.month()
|
||||
: dayjs().month() /* High chance server is going to have the same month */
|
||||
);
|
||||
const [browsingDate, setBrowsingDate] = useState<Dayjs | null>(date);
|
||||
|
||||
useEffect(() => {
|
||||
if (dayjs().month() !== selectedMonth) {
|
||||
setSelectedMonth(dayjs().month());
|
||||
if (!browsingDate || (date && browsingDate.utcOffset() !== date?.utcOffset())) {
|
||||
setBrowsingDate(date || dayjs().tz(timeZone()));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [date, browsingDate]);
|
||||
|
||||
// Handle month changes
|
||||
const incrementMonth = () => {
|
||||
setSelectedMonth((selectedMonth ?? 0) + 1);
|
||||
};
|
||||
|
||||
const decrementMonth = () => {
|
||||
setSelectedMonth((selectedMonth ?? 0) - 1);
|
||||
};
|
||||
|
||||
const inviteeDate = (): Dayjs => (date || dayjs()).month(selectedMonth);
|
||||
|
||||
useEffect(() => {
|
||||
const days = useMemo(() => {
|
||||
if (!browsingDate) {
|
||||
return [];
|
||||
}
|
||||
// Create placeholder elements for empty days in first week
|
||||
let weekdayOfFirst = inviteeDate().date(1).day();
|
||||
let weekdayOfFirst = browsingDate.startOf("month").day();
|
||||
if (weekStart === "Monday") {
|
||||
weekdayOfFirst -= 1;
|
||||
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
|
||||
|
@ -81,65 +106,45 @@ function DatePicker({
|
|||
const days = Array(weekdayOfFirst).fill(null);
|
||||
|
||||
const isDisabled = (day: number) => {
|
||||
const date: Dayjs = inviteeDate().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 (
|
||||
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
|
||||
date.endOf("day").isAfter(periodRollingEndDay) ||
|
||||
!getSlots({
|
||||
inviteeDate: date,
|
||||
frequency: eventLength,
|
||||
minimumBookingNotice,
|
||||
workingHours,
|
||||
}).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 date = browsingDate.startOf("day").date(day);
|
||||
return (
|
||||
isOutOfBounds(date, {
|
||||
periodType,
|
||||
periodStartDate,
|
||||
periodEndDate,
|
||||
periodCountCalendarDays,
|
||||
periodDays,
|
||||
}) ||
|
||||
!getSlots({
|
||||
inviteeDate: date,
|
||||
frequency: eventLength,
|
||||
minimumBookingNotice,
|
||||
workingHours,
|
||||
}).length
|
||||
);
|
||||
};
|
||||
|
||||
const daysInMonth = inviteeDate().daysInMonth();
|
||||
const daysInMonth = browsingDate.daysInMonth();
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push({ disabled: isDisabled(i), date: i });
|
||||
}
|
||||
|
||||
setDays(days);
|
||||
return days;
|
||||
// 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 (
|
||||
<div
|
||||
|
@ -152,20 +157,18 @@ function DatePicker({
|
|||
<div className="flex mb-4 text-xl font-light text-gray-600">
|
||||
<span className="w-1/2 text-gray-600 dark:text-white">
|
||||
<strong className="text-gray-900 dark:text-white">
|
||||
{t(inviteeDate().format("MMMM").toLowerCase())}
|
||||
{t(browsingDate.format("MMMM").toLowerCase())}
|
||||
</strong>{" "}
|
||||
<span className="text-gray-500">{inviteeDate().format("YYYY")}</span>
|
||||
<span className="text-gray-500">{browsingDate.format("YYYY")}</span>
|
||||
</span>
|
||||
<div className="w-1/2 text-right text-gray-600 dark:text-gray-400">
|
||||
<button
|
||||
onClick={decrementMonth}
|
||||
className={classNames(
|
||||
"group mr-2 p-1",
|
||||
typeof selectedMonth === "number" &&
|
||||
selectedMonth <= dayjs().month() &&
|
||||
"text-gray-400 dark:text-gray-600"
|
||||
browsingDate.startOf("month").isBefore(dayjs()) && "text-gray-400 dark:text-gray-600"
|
||||
)}
|
||||
disabled={typeof selectedMonth === "number" && selectedMonth <= dayjs().month()}
|
||||
disabled={browsingDate.startOf("month").isBefore(dayjs())}
|
||||
data-testid="decrementMonth">
|
||||
<ChevronLeftIcon className="w-5 h-5 group-hover:text-black dark:group-hover:text-white" />
|
||||
</button>
|
||||
|
@ -195,13 +198,13 @@ function DatePicker({
|
|||
<div key={`e-${idx}`} />
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onDatePicked(inviteeDate().date(day.date))}
|
||||
onClick={() => onDatePicked(browsingDate.date(day.date))}
|
||||
disabled={day.disabled}
|
||||
className={classNames(
|
||||
"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",
|
||||
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"
|
||||
: !day.disabled
|
||||
? " bg-gray-100 dark:bg-gray-600 dark:text-white"
|
||||
|
|
|
@ -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) => {
|
||||
// Get times localised to the given utcOffset/timeZone
|
||||
|
|
|
@ -107,7 +107,6 @@ export const useSlots = (props: UseSlotsProps) => {
|
|||
workingHours: responseBody.workingHours,
|
||||
minimumBookingNotice,
|
||||
});
|
||||
|
||||
// Check for conflicts
|
||||
for (let i = times.length - 1; i >= 0; i -= 1) {
|
||||
responseBody.busy.every((busyTime): boolean => {
|
||||
|
|
22
lib/slots.ts
22
lib/slots.ts
|
@ -19,7 +19,7 @@ export type GetSlots = {
|
|||
|
||||
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
|
||||
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
|
||||
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
|
||||
const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours }: GetSlots) => {
|
||||
// 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
|
||||
if (startDate.isBefore(dayjs(), "day")) {
|
||||
if (inviteeDate.isBefore(startDate, "day")) {
|
||||
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(
|
||||
{ utcOffset: -inviteeDate.utcOffset() },
|
||||
|
@ -47,14 +43,18 @@ const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours }
|
|||
).filter((hours) => hours.days.includes(inviteeDate.day()));
|
||||
|
||||
const slots: Dayjs[] = [];
|
||||
for (let minutes = getMinuteOffset(startDate, frequency); minutes < 1440; minutes += frequency) {
|
||||
const slot = startDate.startOf("day").add(minutes, "minute");
|
||||
for (let minutes = getMinuteOffset(inviteeDate, frequency); minutes < 1440; minutes += frequency) {
|
||||
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.
|
||||
if (
|
||||
localWorkingHours.some((hours) =>
|
||||
slot.isBetween(
|
||||
startDate.startOf("day").add(hours.startTime, "minute"),
|
||||
startDate.startOf("day").add(hours.endTime, "minute"),
|
||||
inviteeDate.startOf("day").add(hours.startTime, "minute"),
|
||||
inviteeDate.startOf("day").add(hours.endTime, "minute"),
|
||||
null,
|
||||
"[)"
|
||||
)
|
||||
|
|
|
@ -121,9 +121,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
periodStartDate: req.body.periodStartDate,
|
||||
periodEndDate: req.body.periodEndDate,
|
||||
periodCountCalendarDays: req.body.periodCountCalendarDays,
|
||||
minimumBookingNotice: req.body.minimumBookingNotice
|
||||
? parseInt(req.body.minimumBookingNotice)
|
||||
: undefined,
|
||||
minimumBookingNotice:
|
||||
req.body.minimumBookingNotice || req.body.minimumBookingNotice === 0
|
||||
? parseInt(req.body.minimumBookingNotice, 10)
|
||||
: undefined,
|
||||
price: req.body.price,
|
||||
currency: req.body.currency,
|
||||
};
|
||||
|
|
|
@ -16,7 +16,7 @@ it("can fit 24 hourly slots for an empty day", async () => {
|
|||
// 24h in a day.
|
||||
expect(
|
||||
getSlots({
|
||||
inviteeDate: dayjs().add(1, "day"),
|
||||
inviteeDate: dayjs.utc().add(1, "day").startOf("day"),
|
||||
frequency: 60,
|
||||
minimumBookingNotice: 0,
|
||||
workingHours: [
|
||||
|
@ -30,11 +30,12 @@ it("can fit 24 hourly slots for an empty day", async () => {
|
|||
).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.
|
||||
expect(
|
||||
getSlots({
|
||||
inviteeDate: dayjs(),
|
||||
inviteeDate: dayjs.utc(),
|
||||
frequency: 60,
|
||||
minimumBookingNotice: 0,
|
||||
workingHours: [
|
||||
|
@ -65,7 +66,7 @@ it("can cut off dates that due to invitee timezone differences fall on the next
|
|||
).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 = [
|
||||
{
|
||||
days: [0],
|
||||
|
@ -75,10 +76,28 @@ it.skip("can cut off dates that due to invitee timezone differences fall on the
|
|||
];
|
||||
expect(
|
||||
getSlots({
|
||||
inviteeDate: dayjs().startOf("day"), // time translation -01:00
|
||||
inviteeDate: dayjs().tz("Atlantic/Cape_Verde").startOf("day"), // time translation -01:00
|
||||
frequency: 60,
|
||||
minimumBookingNotice: 0,
|
||||
workingHours,
|
||||
})
|
||||
).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);
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue