Fixes user availability to be contextual to the user timezone (#1166)

* WIP, WIP, WIP, WIP

* Adds missing types

* Type fixes for useSlots

* Type fixes

* Fixes periodType 500 error when updating

* Adds missing dayjs plugin and type fixes

* An attempt was made to fix tests

* Save work in progress

* Added UTC overflow to days

* Update lib/availability.ts

Co-authored-by: Alex Johansson <alexander@n1s.se>

* No more magic numbers

* Fixed slots.test & added getWorkingHours.test

* Tests pass, simpler logic, profit?

* Timezone shifting!

* Forgot to unskip tests

* Updated the user page

* Added American seed user, some fixes

* tmp fix so to continue testing availability

* Removed timeZone parameter, fix defaultValue auto-scroll

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Alex Johansson <alexander@n1s.se>
This commit is contained in:
Alex van Andel 2021-11-18 01:03:19 +00:00 committed by GitHub
parent f3c95fa3de
commit ffdf0b9217
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 587 additions and 333 deletions

View File

@ -11,11 +11,6 @@ import { useSlots } from "@lib/hooks/useSlots";
import Loader from "@components/Loader";
type AvailableTimesProps = {
workingHours: {
days: number[];
startTime: number;
endTime: number;
}[];
timeFormat: string;
minimumBookingNotice: number;
eventTypeId: number;
@ -32,7 +27,6 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
eventLength,
eventTypeId,
minimumBookingNotice,
workingHours,
timeFormat,
users,
schedulingType,
@ -45,16 +39,15 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
date,
eventLength,
schedulingType,
workingHours,
users,
minimumBookingNotice,
eventTypeId,
});
return (
<div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:-mb-5">
<div className="text-gray-600 font-light text-lg mb-4 text-left">
<span className="w-1/2 dark:text-white text-gray-600">
<div className="mt-8 text-center sm:pl-4 sm:mt-0 sm:w-1/3 md:-mb-5">
<div className="mb-4 text-lg font-light text-left text-gray-600">
<span className="w-1/2 text-gray-600 dark:text-white">
<strong>{t(date.format("dddd").toLowerCase())}</strong>
<span className="text-gray-500">
{date.format(", DD ")}
@ -91,7 +84,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
<div key={slot.time.format()}>
<Link href={bookingUrl}>
<a
className="block font-medium mb-2 bg-white dark:bg-gray-600 text-primary-500 dark:text-neutral-200 border border-brand dark:border-transparent rounded-sm hover:text-white hover:bg-brand dark:hover:border-black py-4 dark:hover:bg-black"
className="block py-4 mb-2 font-medium bg-white border rounded-sm dark:bg-gray-600 text-primary-500 dark:text-neutral-200 border-brand dark:border-transparent hover:text-white hover:bg-brand dark:hover:border-black dark:hover:bg-black"
data-testid="time">
{slot.time.format(timeFormat)}
</a>
@ -100,7 +93,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
);
})}
{!loading && !error && !slots.length && (
<div className="w-full h-full flex flex-col justify-center content-center items-center -mt-4">
<div className="flex flex-col items-center content-center justify-center w-full h-full -mt-4">
<h1 className="my-6 text-xl text-black dark:text-white">{t("all_booked_today")}</h1>
</div>
)}
@ -108,10 +101,10 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
{loading && <Loader />}
{error && (
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4">
<div className="p-4 border-l-4 border-yellow-400 bg-yellow-50">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
<ExclamationIcon className="w-5 h-5 text-yellow-400" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm text-yellow-700">{t("slots_load_fail")}</p>

View File

@ -1,40 +1,52 @@
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
import { 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 classNames from "@lib/classNames";
import { useLocale } from "@lib/hooks/useLocale";
import getSlots from "@lib/slots";
import { WorkingHours } from "@lib/types/schedule";
dayjs.extend(dayjsBusinessTime);
dayjs.extend(utc);
dayjs.extend(timezone);
// FIXME prop types
type DatePickerProps = {
weekStart: string;
onDatePicked: (pickedDate: Dayjs) => void;
workingHours: WorkingHours[];
eventLength: number;
date: Dayjs | null;
periodType: string;
periodStartDate: Date | null;
periodEndDate: Date | null;
periodDays: number | null;
periodCountCalendarDays: boolean | null;
minimumBookingNotice: number;
};
function DatePicker({
weekStart,
onDatePicked,
workingHours,
organizerTimeZone,
eventLength,
date,
periodType = "unlimited",
periodType = PeriodType.UNLIMITED,
periodStartDate,
periodEndDate,
periodDays,
periodCountCalendarDays,
minimumBookingNotice,
}: any): JSX.Element {
}: DatePickerProps): JSX.Element {
const { t } = useLocale();
const [days, setDays] = useState<({ disabled: boolean; date: number } | null)[]>([]);
const [selectedMonth, setSelectedMonth] = useState<number | null>(
const [selectedMonth, setSelectedMonth] = useState<number>(
date
? periodType === "range"
? periodType === PeriodType.RANGE
? dayjs(periodStartDate).utcOffset(date.utcOffset()).month()
: date.month()
: dayjs().month() /* High chance server is going to have the same month */
@ -71,10 +83,13 @@ function DatePicker({
const isDisabled = (day: number) => {
const date: Dayjs = inviteeDate().date(day);
switch (periodType) {
case "rolling": {
case PeriodType.ROLLING: {
if (!periodDays) {
throw new Error("PeriodType rolling requires periodDays");
}
const periodRollingEndDay = periodCountCalendarDays
? dayjs().tz(organizerTimeZone).add(periodDays, "days").endOf("day")
: dayjs().tz(organizerTimeZone).addBusinessTime(periodDays, "days").endOf("day");
? 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) ||
@ -83,14 +98,13 @@ function DatePicker({
frequency: eventLength,
minimumBookingNotice,
workingHours,
organizerTimeZone,
}).length
);
}
case "range": {
const periodRangeStartDay = dayjs(periodStartDate).tz(organizerTimeZone).endOf("day");
const periodRangeEndDay = dayjs(periodEndDate).tz(organizerTimeZone).endOf("day");
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) ||
@ -100,12 +114,11 @@ function DatePicker({
frequency: eventLength,
minimumBookingNotice,
workingHours,
organizerTimeZone,
}).length
);
}
case "unlimited":
case PeriodType.UNLIMITED:
default:
return (
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
@ -114,7 +127,6 @@ function DatePicker({
frequency: eventLength,
minimumBookingNotice,
workingHours,
organizerTimeZone,
}).length
);
}
@ -137,7 +149,7 @@ function DatePicker({
? "w-full sm:w-1/2 md:w-1/3 sm:border-r sm:dark:border-gray-800 sm:pl-4 sm:pr-6 "
: "w-full sm:pl-4")
}>
<div className="flex text-gray-600 font-light text-xl mb-4">
<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())}
@ -155,18 +167,18 @@ function DatePicker({
)}
disabled={typeof selectedMonth === "number" && selectedMonth <= dayjs().month()}
data-testid="decrementMonth">
<ChevronLeftIcon className="group-hover:text-black dark:group-hover:text-white w-5 h-5" />
<ChevronLeftIcon className="w-5 h-5 group-hover:text-black dark:group-hover:text-white" />
</button>
<button className="group p-1" onClick={incrementMonth} data-testid="incrementMonth">
<ChevronRightIcon className="group-hover:text-black dark:group-hover:text-white w-5 h-5" />
<button className="p-1 group" onClick={incrementMonth} data-testid="incrementMonth">
<ChevronRightIcon className="w-5 h-5 group-hover:text-black dark:group-hover:text-white" />
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-4 text-center border-b border-t dark:border-gray-800 sm:border-0">
<div className="grid grid-cols-7 gap-4 text-center border-t border-b dark:border-gray-800 sm:border-0">
{["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
.sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0))
.map((weekDay) => (
<div key={weekDay} className="uppercase text-gray-500 text-xs tracking-widest my-4">
<div key={weekDay} className="my-4 text-xs tracking-widest text-gray-500 uppercase">
{t(weekDay.toLowerCase()).substring(0, 3)}
</div>
))}
@ -178,7 +190,7 @@ function DatePicker({
style={{
paddingTop: "100%",
}}
className="w-full relative">
className="relative w-full">
{day === null ? (
<div key={`e-${idx}`} />
) : (

View File

@ -93,8 +93,8 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
<HeadSeo
title={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title} | ${profile.name}`}
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
name={profile.name}
avatar={profile.image}
name={profile.name || undefined}
avatar={profile.image || undefined}
/>
<CustomBranding val={profile.brandColor} />
<div>
@ -109,14 +109,18 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
<div className="block p-4 sm:p-8 md:hidden">
<div className="flex items-center">
<AvatarGroup
items={[{ image: profile.image, alt: profile.name }].concat(
eventType.users
.filter((user) => user.name !== profile.name)
.map((user) => ({
title: user.name,
image: user.avatar,
}))
)}
items={
[
{ image: profile.image, alt: profile.name, title: profile.name },
...eventType.users
.filter((user) => user.name !== profile.name)
.map((user) => ({
title: user.name,
image: user.avatar || undefined,
alt: user.name || undefined,
})),
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
}
size={9}
truncateAfter={5}
/>
@ -153,14 +157,18 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
(selectedDate ? "sm:w-1/3" : "sm:w-1/2")
}>
<AvatarGroup
items={[{ image: profile.image, alt: profile.name }].concat(
eventType.users
.filter((user) => user.name !== profile.name)
.map((user) => ({
title: user.name,
image: user.avatar,
}))
)}
items={
[
{ image: profile.image, alt: profile.name, title: profile.name },
...eventType.users
.filter((user) => user.name !== profile.name)
.map((user) => ({
title: user.name,
alt: user.name,
image: user.avatar,
})),
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
}
size={10}
truncateAfter={3}
/>
@ -209,7 +217,6 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
{selectedDate && (
<AvailableTimes
workingHours={workingHours}
timeFormat={timeFormat}
minimumBookingNotice={eventType.minimumBookingNotice}
eventTypeId={eventType.id}

View File

@ -12,7 +12,7 @@ export type AvatarGroupProps = {
items: {
image: string;
title?: string;
alt: string;
alt?: string;
}[];
className?: string;
};
@ -30,17 +30,17 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
<ul className={classNames("flex -space-x-2 overflow-hidden", props.className)}>
{props.items.slice(0, props.truncateAfter).map((item, idx) => (
<li key={idx} className="inline-block">
<Avatar imageSrc={item.image} title={item.title} alt={item.alt} size={props.size} />
<Avatar imageSrc={item.image} title={item.title} alt={item.alt || ""} size={props.size} />
</li>
))}
{/*props.items.length > props.truncateAfter && (
<li className="inline-block relative">
<li className="relative inline-block">
<Tooltip.Tooltip delayDuration="300">
<Tooltip.TooltipTrigger className="cursor-default">
<span className="w-16 absolute bottom-1.5 border-2 border-gray-300 flex-inline items-center text-white pt-4 text-2xl top-0 rounded-full block bg-neutral-600">+1</span>
</Tooltip.TooltipTrigger>
{truncatedAvatars.length !== 0 && (
<Tooltip.Content className="p-2 rounded-sm text-sm bg-brand text-white shadow-sm">
<Tooltip.Content className="p-2 text-sm text-white rounded-sm shadow-sm bg-brand">
<Tooltip.Arrow />
<ul>
{truncatedAvatars.map((title) => (

View File

@ -7,7 +7,7 @@ import React, { useEffect, useState } from "react";
import TimezoneSelect, { ITimezoneOption } from "react-timezone-select";
import { useLocale } from "@lib/hooks/useLocale";
import { OpeningHours, DateOverride } from "@lib/types/event-type";
import { WorkingHours } from "@lib/types/schedule";
import { WeekdaySelect } from "./WeekdaySelect";
import SetTimesModal from "./modal/SetTimesModal";
@ -19,7 +19,7 @@ type Props = {
timeZone: string;
availability: Availability[];
setTimeZone: (timeZone: string) => void;
setAvailability: (schedule: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] }) => void;
setAvailability: (schedule: { openingHours: WorkingHours[]; dateOverrides: WorkingHours[] }) => void;
};
/**

View File

@ -1,6 +1,8 @@
import { PlusIcon, TrashIcon } from "@heroicons/react/outline";
import dayjs, { Dayjs } from "dayjs";
import React, { useCallback, useState } from "react";
import dayjs, { Dayjs, ConfigType } from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import React from "react";
import { Controller, useFieldArray } from "react-hook-form";
import { defaultDayRange } from "@lib/availability";
@ -11,6 +13,9 @@ import { TimeRange } from "@lib/types/schedule";
import Button from "@components/ui/Button";
import Select from "@components/ui/form/Select";
dayjs.extend(utc);
dayjs.extend(timezone);
/** Begin Time Increments For Select */
const increment = 15;
/**
@ -31,30 +36,17 @@ const TIMES = (() => {
})();
/** End Time Increments For Select */
type Option = {
readonly label: string;
readonly value: number;
};
type TimeRangeFieldProps = {
name: string;
};
const TimeRangeField = ({ name }: TimeRangeFieldProps) => {
// Lazy-loaded options, otherwise adding a field has a noticable redraw delay.
const [options, setOptions] = useState<Option[]>([]);
const getOption = (time: Date) => ({
value: time.valueOf(),
label: time.toLocaleTimeString("nl-NL", { minute: "numeric", hour: "numeric" }),
const getOption = (time: ConfigType) => ({
value: dayjs(time).utc(true).toDate().valueOf(),
label: dayjs(time).toDate().toLocaleTimeString("nl-NL", { minute: "numeric", hour: "numeric" }),
});
const timeOptions = useCallback((offsetOrLimit: { offset?: number; limit?: number } = {}) => {
const { limit, offset } = offsetOrLimit;
return TIMES.filter((time) => (!limit || time.isBefore(limit)) && (!offset || time.isAfter(offset))).map(
(t) => getOption(t.toDate())
);
}, []);
const timeOptions = TIMES.map((t) => getOption(t));
return (
<>
@ -63,10 +55,10 @@ const TimeRangeField = ({ name }: TimeRangeFieldProps) => {
render={({ field: { onChange, value } }) => (
<Select
className="w-[6rem]"
options={options}
onFocus={() => setOptions(timeOptions())}
onBlur={() => setOptions([])}
defaultValue={getOption(value)}
options={timeOptions}
value={timeOptions.filter(function (option) {
return option.value === getOption(value).value;
})}
onChange={(option) => onChange(new Date(option?.value as number))}
/>
)}
@ -77,10 +69,10 @@ const TimeRangeField = ({ name }: TimeRangeFieldProps) => {
render={({ field: { onChange, value } }) => (
<Select
className="w-[6rem]"
options={options}
onFocus={() => setOptions(timeOptions())}
onBlur={() => setOptions([])}
defaultValue={getOption(value)}
options={timeOptions}
value={timeOptions.filter(function (option) {
return option.value === getOption(value).value;
})}
onChange={(option) => onChange(new Date(option?.value as number))}
/>
)}

View File

@ -1,11 +1,18 @@
import { Availability } from "@prisma/client";
import dayjs, { ConfigType } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { Schedule, TimeRange } from "./types/schedule";
import { Schedule, TimeRange, WorkingHours } from "./types/schedule";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(customParseFormat);
// sets the desired time in current date, needs to be current date for proper DST translation
export const defaultDayRange: TimeRange = {
start: new Date(new Date().setHours(9, 0, 0, 0)),
end: new Date(new Date().setHours(17, 0, 0, 0)),
start: new Date(new Date().setUTCHours(9, 0, 0, 0)),
end: new Date(new Date().setUTCHours(17, 0, 0, 0)),
};
export const DEFAULT_SCHEDULE: Schedule = [
@ -45,3 +52,75 @@ export function getAvailabilityFromSchedule(schedule: Schedule): Availability[]
return availability;
}, [] as Availability[]);
}
export const MINUTES_IN_DAY = 60 * 24;
export const MINUTES_DAY_END = MINUTES_IN_DAY - 1;
export const MINUTES_DAY_START = 0;
/**
* Allows "casting" availability (days, startTime, endTime) given in UTC to a timeZone or utcOffset
*/
export function getWorkingHours(
relativeTimeUnit: {
timeZone?: string;
utcOffset?: number;
},
availability: { days: number[]; startTime: ConfigType; endTime: ConfigType }[]
) {
// clearly bail when availability is not set, set everything available.
if (!availability.length) {
return [
{
days: [0, 1, 2, 3, 4, 5, 6],
// shorthand for: dayjs().startOf("day").tz(timeZone).diff(dayjs.utc().startOf("day"), "minutes")
startTime: MINUTES_DAY_START,
endTime: MINUTES_DAY_END,
},
];
}
const utcOffset = relativeTimeUnit.utcOffset || dayjs().tz(relativeTimeUnit.timeZone).utcOffset();
const workingHours = availability.reduce((workingHours: WorkingHours[], schedule) => {
// Get times localised to the given utcOffset/timeZone
const startTime =
dayjs.utc(schedule.startTime).get("hour") * 60 +
dayjs.utc(schedule.startTime).get("minute") -
utcOffset;
const endTime =
dayjs.utc(schedule.endTime).get("hour") * 60 + dayjs.utc(schedule.endTime).get("minute") - utcOffset;
// add to working hours, keeping startTime and endTimes between bounds (0-1439)
const sameDayStartTime = Math.max(MINUTES_DAY_START, Math.min(MINUTES_DAY_END, startTime));
const sameDayEndTime = Math.max(MINUTES_DAY_START, Math.min(MINUTES_DAY_END, endTime));
if (sameDayStartTime !== sameDayEndTime) {
workingHours.push({
days: schedule.days,
startTime: sameDayStartTime,
endTime: sameDayEndTime,
});
}
// check for overflow to the previous day
if (startTime < MINUTES_DAY_START || endTime < MINUTES_DAY_START) {
workingHours.push({
days: schedule.days.map((day) => day - 1),
startTime: startTime + MINUTES_IN_DAY,
endTime: Math.min(endTime + MINUTES_IN_DAY, MINUTES_DAY_END),
});
}
// else, check for overflow in the next day
else if (startTime > MINUTES_DAY_END || endTime > MINUTES_DAY_END) {
workingHours.push({
days: schedule.days.map((day) => day + 1),
startTime: Math.max(startTime - MINUTES_IN_DAY, MINUTES_DAY_START),
endTime: endTime - MINUTES_IN_DAY,
});
}
return workingHours;
}, []);
workingHours.sort((a, b) => a.startTime - b.startTime);
return workingHours;
}

View File

@ -1,4 +1,4 @@
import { Availability, SchedulingType } from "@prisma/client";
import { SchedulingType } from "@prisma/client";
import dayjs, { Dayjs } from "dayjs";
import isBetween from "dayjs/plugin/isBetween";
import utc from "dayjs/plugin/utc";
@ -6,16 +6,15 @@ import { stringify } from "querystring";
import { useEffect, useState } from "react";
import getSlots from "@lib/slots";
import { FreeBusyTime } from "@components/ui/Schedule/Schedule";
import { TimeRange, WorkingHours } from "@lib/types/schedule";
dayjs.extend(isBetween);
dayjs.extend(utc);
type AvailabilityUserResponse = {
busy: FreeBusyTime;
busy: TimeRange[];
timeZone: string;
workingHours: Availability[];
workingHours: WorkingHours[];
};
type Slot = {
@ -28,11 +27,6 @@ type UseSlotsProps = {
eventTypeId: number;
minimumBookingNotice?: number;
date: Dayjs;
workingHours: {
days: number[];
startTime: number;
endTime: number;
}[];
users: { username: string | null }[];
schedulingType: SchedulingType | null;
};
@ -52,65 +46,66 @@ export const useSlots = (props: UseSlotsProps) => {
const dateTo = date.endOf("day").format();
const query = stringify({ dateFrom, dateTo, eventTypeId });
Promise.all(
users.map((user) =>
fetch(`/api/availability/${user.username}?${query}`)
.then(handleAvailableSlots)
.catch((e) => {
console.error(e);
setError(e);
})
)
).then((results) => {
let loadedSlots: Slot[] = results[0];
if (results.length === 1) {
loadedSlots = loadedSlots?.sort((a, b) => (a.time.isAfter(b.time) ? 1 : -1));
Promise.all<Slot[]>(
users.map((user) => fetch(`/api/availability/${user.username}?${query}`).then(handleAvailableSlots))
)
.then((results) => {
let loadedSlots: Slot[] = results[0] || [];
if (results.length === 1) {
loadedSlots = loadedSlots?.sort((a, b) => (a.time.isAfter(b.time) ? 1 : -1));
setSlots(loadedSlots);
setLoading(false);
return;
}
let poolingMethod;
switch (props.schedulingType) {
// intersect by time, does not take into account eventLength (yet)
case SchedulingType.COLLECTIVE:
poolingMethod = (slots: Slot[], compareWith: Slot[]) =>
slots.filter((slot) => compareWith.some((compare) => compare.time.isSame(slot.time)));
break;
case SchedulingType.ROUND_ROBIN:
// TODO: Create a Reservation (lock this slot for X minutes)
// this will make the following code redundant
poolingMethod = (slots: Slot[], compareWith: Slot[]) => {
compareWith.forEach((compare) => {
const match = slots.findIndex((slot) => slot.time.isSame(compare.time));
if (match !== -1) {
slots[match].users?.push(compare.users![0]);
} else {
slots.push(compare);
}
});
return slots;
};
break;
}
if (!poolingMethod) {
throw Error(`No poolingMethod found for schedulingType: "${props.schedulingType}""`);
}
for (let i = 1; i < results.length; i++) {
loadedSlots = poolingMethod(loadedSlots, results[i]);
}
loadedSlots = loadedSlots.sort((a, b) => (a.time.isAfter(b.time) ? 1 : -1));
setSlots(loadedSlots);
setLoading(false);
return;
}
let poolingMethod;
switch (props.schedulingType) {
// intersect by time, does not take into account eventLength (yet)
case SchedulingType.COLLECTIVE:
poolingMethod = (slots, compareWith) =>
slots.filter((slot) => compareWith.some((compare) => compare.time.isSame(slot.time)));
break;
case SchedulingType.ROUND_ROBIN:
// TODO: Create a Reservation (lock this slot for X minutes)
// this will make the following code redundant
poolingMethod = (slots, compareWith) => {
compareWith.forEach((compare) => {
const match = slots.findIndex((slot) => slot.time.isSame(compare.time));
if (match !== -1) {
slots[match].users.push(compare.users[0]);
} else {
slots.push(compare);
}
});
return slots;
};
break;
}
for (let i = 1; i < results.length; i++) {
loadedSlots = poolingMethod(loadedSlots, results[i]);
}
loadedSlots = loadedSlots.sort((a, b) => (a.time.isAfter(b.time) ? 1 : -1));
setSlots(loadedSlots);
setLoading(false);
});
})
.catch((e) => {
console.error(e);
setError(e);
});
}, [date]);
const handleAvailableSlots = async (res) => {
const handleAvailableSlots = async (res: Response) => {
const responseBody: AvailabilityUserResponse = await res.json();
const times = getSlots({
frequency: eventLength,
inviteeDate: date,
workingHours: responseBody.workingHours,
minimumBookingNotice,
organizerTimeZone: responseBody.timeZone,
});
// Check for conflicts

View File

@ -1,137 +1,63 @@
import dayjs, { Dayjs } from "dayjs";
import timezone from "dayjs/plugin/timezone";
import isBetween from "dayjs/plugin/isBetween";
import utc from "dayjs/plugin/utc";
import { getWorkingHours } from "./availability";
import { WorkingHours } from "./types/schedule";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(isBetween);
type WorkingHour = {
days: number[];
startTime: number;
endTime: number;
};
type GetSlots = {
export type GetSlots = {
inviteeDate: Dayjs;
frequency: number;
workingHours: WorkingHour[];
minimumBookingNotice?: number;
organizerTimeZone: string;
workingHours: WorkingHours[];
minimumBookingNotice: number;
};
type Boundary = {
lowerBound: number;
upperBound: 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
const minuteOffset = Math.min(date.diff(dayjs().startOf("day"), "minutes"), 1440) % 1440;
// round down to nearest step
return Math.floor(minuteOffset / step) * step;
};
const freqApply = (cb, value: number, frequency: number): number => cb(value / frequency) * frequency;
const intersectBoundary = (a: Boundary, b: Boundary) => {
if (a.upperBound < b.lowerBound || a.lowerBound > b.upperBound) {
return;
const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours }: GetSlots) => {
// current date in invitee tz
const startDate = dayjs(inviteeDate).add(minimumBookingNotice, "minutes"); // + minimum notice period
// checks if the start date is in the past
if (startDate.isBefore(dayjs(), "day")) {
return [];
}
return {
lowerBound: Math.max(b.lowerBound, a.lowerBound),
upperBound: Math.min(b.upperBound, a.upperBound),
};
};
// say invitee is -60,1380, and boundary is -120,240 - the overlap is -60,240
const getOverlaps = (inviteeBoundary: Boundary, boundaries: Boundary[]) =>
boundaries.map((boundary) => intersectBoundary(inviteeBoundary, boundary)).filter(Boolean);
const localWorkingHours = getWorkingHours(
{ utcOffset: -inviteeDate.utcOffset() },
workingHours.map((schedule) => ({
days: schedule.days,
startTime: dayjs.utc().startOf("day").add(schedule.startTime, "minutes"),
endTime: dayjs.utc().startOf("day").add(schedule.endTime, "minutes"),
}))
).filter((hours) => hours.days.includes(inviteeDate.day()));
const organizerBoundaries = (
workingHours: [],
inviteeDate: Dayjs,
inviteeBounds: Boundary,
organizerTimeZone
): Boundary[] => {
const boundaries: Boundary[] = [];
const startDay: number = +inviteeDate.startOf("d").add(inviteeBounds.lowerBound, "minutes").format("d");
const endDay: number = +inviteeDate.startOf("d").add(inviteeBounds.upperBound, "minutes").format("d");
workingHours.forEach((item) => {
const lowerBound: number = item.startTime - dayjs().tz(organizerTimeZone).utcOffset();
const upperBound: number = item.endTime - dayjs().tz(organizerTimeZone).utcOffset();
if (startDay !== endDay) {
if (inviteeBounds.lowerBound < 0) {
// lowerBound edges into the previous day
if (item.days.includes(startDay)) {
boundaries.push({ lowerBound: lowerBound - 1440, upperBound: upperBound - 1440 });
}
if (item.days.includes(endDay)) {
boundaries.push({ lowerBound, upperBound });
}
} else {
// upperBound edges into the next day
if (item.days.includes(endDay)) {
boundaries.push({ lowerBound: lowerBound + 1440, upperBound: upperBound + 1440 });
}
if (item.days.includes(startDay)) {
boundaries.push({ lowerBound, upperBound });
}
}
} else {
if (item.days.includes(startDay)) {
boundaries.push({ lowerBound, upperBound });
}
}
});
return boundaries;
};
const inviteeBoundary = (startTime: number, utcOffset: number, frequency: number): Boundary => {
const upperBound: number = freqApply(Math.floor, 1440 - utcOffset, frequency);
const lowerBound: number = freqApply(Math.ceil, startTime - utcOffset, frequency);
return {
lowerBound,
upperBound,
};
};
const getSlotsBetweenBoundary = (frequency: number, { lowerBound, upperBound }: Boundary) => {
const slots: Dayjs[] = [];
for (let minutes = 0; lowerBound + minutes <= upperBound - frequency; minutes += frequency) {
slots.push(
dayjs
.utc()
.startOf("d")
.add(lowerBound + minutes, "minutes")
);
for (let minutes = getMinuteOffset(inviteeDate, frequency); minutes < 1440; minutes += frequency) {
const slot = inviteeDate.startOf("day").add(minutes, "minutes");
// 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(
inviteeDate.startOf("day").add(hours.startTime, "minutes"),
inviteeDate.startOf("day").add(hours.endTime, "minutes"),
null,
"[)"
)
)
) {
slots.push(slot);
}
}
return slots;
};
const getSlots = ({
inviteeDate,
frequency,
minimumBookingNotice,
workingHours,
organizerTimeZone,
}: GetSlots): Dayjs[] => {
// current date in invitee tz
const currentDate = dayjs().utcOffset(inviteeDate.utcOffset());
const startDate = currentDate.add(minimumBookingNotice, "minutes"); // + minimum notice period
const startTime = startDate.isAfter(inviteeDate)
? // block out everything when inviteeDate is less than startDate
startDate.diff(inviteeDate, "day") > 0
? 1440
: startDate.hour() * 60 + startDate.minute()
: 0;
const inviteeBounds = inviteeBoundary(startTime, inviteeDate.utcOffset(), frequency);
return getOverlaps(
inviteeBounds,
organizerBoundaries(workingHours, inviteeDate, inviteeBounds, organizerTimeZone)
)
.reduce((slots, boundary: Boundary) => [...slots, ...getSlotsBetweenBoundary(frequency, boundary)], [])
.map((slot) =>
slot.utcOffset(inviteeDate.utcOffset()).month(inviteeDate.month()).date(inviteeDate.date())
);
};
export default getSlots;

View File

@ -1,7 +1,6 @@
import { SchedulingType, EventType, Availability } from "@prisma/client";
import { EventType, SchedulingType } from "@prisma/client";
export type OpeningHours = Pick<Availability, "days" | "startTime" | "endTime">;
export type DateOverride = Pick<Availability, "date" | "startTime" | "endTime">;
import { WorkingHours } from "./schedule";
export type AdvancedOptions = {
eventName?: string;
@ -21,7 +20,7 @@ export type AdvancedOptions = {
label: string;
avatar: string;
}[];
availability?: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] };
availability?: { openingHours: WorkingHours[]; dateOverrides: WorkingHours[] };
customInputs?: EventTypeCustomInput[];
timeZone: string;
hidden: boolean;
@ -58,5 +57,5 @@ export type EventTypeInput = AdvancedOptions & {
locations: unknown;
customInputs: EventTypeCustomInput[];
timeZone: string;
availability?: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] };
availability?: { openingHours: WorkingHours[]; dateOverrides: WorkingHours[] };
};

View File

@ -4,3 +4,15 @@ export type TimeRange = {
};
export type Schedule = TimeRange[][];
/**
* ```text
* Ensure startTime and endTime in minutes since midnight; serialized to UTC by using the organizer timeZone, either by using the schedule timeZone or the user timeZone.
* @see lib/availability.ts getWorkingHours(timeZone: string, availability: Availability[])
* ```
*/
export type WorkingHours = {
days: number[];
startTime: number;
endTime: number;
};

View File

@ -2,6 +2,7 @@ import { Prisma } from "@prisma/client";
import { GetServerSidePropsContext } from "next";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -42,6 +43,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
periodCountCalendarDays: true,
schedulingType: true,
minimumBookingNotice: true,
timeZone: true,
users: {
select: {
avatar: true,
@ -49,6 +51,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
username: true,
hideBranding: true,
plan: true,
timeZone: true,
},
},
});
@ -120,6 +123,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
username: user.username,
hideBranding: user.hideBranding,
plan: user.plan,
timeZone: user.timeZone,
});
user.eventTypes.push(eventTypeBackwardsCompat);
}
@ -156,33 +160,19 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
} as const;
}
}*/
const getWorkingHours = (availability: typeof user.availability | typeof eventType.availability) =>
availability && availability.length
? availability.map((schedule) => ({
...schedule,
startTime: schedule.startTime.getHours() * 60 + schedule.startTime.getMinutes(),
endTime: schedule.endTime.getHours() * 60 + schedule.endTime.getMinutes(),
}))
: null;
const workingHours =
getWorkingHours(eventType.availability) ||
getWorkingHours(user.availability) ||
[
{
days: [0, 1, 2, 3, 4, 5, 6],
startTime: user.startTime,
endTime: user.endTime,
},
].filter((availability): boolean => typeof availability["days"] !== "undefined");
workingHours.sort((a, b) => a.startTime - b.startTime);
const eventTypeObject = Object.assign({}, eventType, {
periodStartDate: eventType.periodStartDate?.toString() ?? null,
periodEndDate: eventType.periodEndDate?.toString() ?? null,
});
const workingHours = getWorkingHours(
{
timeZone: user.timeZone,
},
eventType.availability.length ? eventType.availability : user.availability
);
eventTypeObject.availability = [];
return {

View File

@ -6,6 +6,7 @@ import utc from "dayjs/plugin/utc";
import type { NextApiRequest, NextApiResponse } from "next";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
import { getBusyCalendarTimes } from "@lib/calendarClient";
import prisma from "@lib/prisma";
@ -76,26 +77,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}));
const timeZone = eventType?.timeZone || currentUser.timeZone;
const workingHours = eventType?.availability.length ? eventType.availability : currentUser.availability;
// FIXME: Currently the organizer timezone is used for the logic
// refactor to be organizerTimezone unaware, use UTC instead.
const workingHours = getWorkingHours(
{ timeZone },
eventType?.availability.length ? eventType.availability : currentUser.availability
);
res.status(200).json({
busy: bufferedBusyTimes,
timeZone,
workingHours: workingHours
// FIXME: Currently the organizer timezone is used for the logic
// refactor to be organizerTimezone unaware, use UTC instead.
.map((workingHour) => ({
days: workingHour.days,
startTime: dayjs(workingHour.startTime).tz(timeZone).toDate(),
endTime: dayjs(workingHour.endTime).tz(timeZone).toDate(),
}))
.map((workingHour) => ({
days: workingHour.days,
startTime: workingHour.startTime.getHours() * 60 + workingHour.startTime.getMinutes(),
endTime: workingHour.endTime.getHours() * 60 + workingHour.endTime.getMinutes(),
})),
workingHours,
});
}

View File

@ -1,9 +1,13 @@
import { EventTypeCustomInput, MembershipRole, Prisma } from "@prisma/client";
import { EventTypeCustomInput, MembershipRole, Prisma, PeriodType } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
import { OpeningHours } from "@lib/types/event-type";
import { WorkingHours } from "@lib/types/schedule";
function handlePeriodType(periodType: string): PeriodType {
return PeriodType[periodType.toUpperCase()];
}
function handleCustomInputs(customInputs: EventTypeCustomInput[], eventTypeId: number) {
if (!customInputs || !customInputs?.length) return undefined;
@ -112,7 +116,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
locations: req.body.locations,
eventName: req.body.eventName,
customInputs: handleCustomInputs(req.body.customInputs as EventTypeCustomInput[], req.body.id),
periodType: req.body.periodType,
periodType: req.body.periodType ? handlePeriodType(req.body.periodType) : undefined,
periodDays: req.body.periodDays,
periodStartDate: req.body.periodStartDate,
periodEndDate: req.body.periodEndDate,
@ -161,7 +165,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
if (req.body.availability) {
const openingHours: OpeningHours[] = req.body.availability.openingHours || [];
const openingHours: WorkingHours[] = req.body.availability.openingHours || [];
// const overrides = req.body.availability.dateOverrides || [];
const eventTypeId = +req.body.id;

View File

@ -23,7 +23,7 @@ export function AvailabilityForm(props: inferQueryOutput<"viewer.availability">)
const createSchedule = async ({ schedule }: FormValues) => {
const res = await fetch(`/api/schedule`, {
method: "POST",
body: JSON.stringify({ schedule }),
body: JSON.stringify({ schedule, timeZone: props.timeZone }),
headers: {
"Content-Type": "application/json",
},
@ -42,6 +42,7 @@ export function AvailabilityForm(props: inferQueryOutput<"viewer.availability">)
schedule: props.schedule || DEFAULT_SCHEDULE,
},
});
return (
<div className="grid grid-cols-3 gap-2">
<Form

View File

@ -44,8 +44,9 @@ import updateEventType from "@lib/mutations/event-types/update-event-type";
import showToast from "@lib/notification";
import prisma from "@lib/prisma";
import { defaultAvatarSrc } from "@lib/profile";
import { AdvancedOptions, DateOverride, EventTypeInput, OpeningHours } from "@lib/types/event-type";
import { AdvancedOptions, EventTypeInput } from "@lib/types/event-type";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { WorkingHours } from "@lib/types/schedule";
import { Dialog, DialogContent, DialogTrigger } from "@components/Dialog";
import Shell from "@components/Shell";
@ -113,8 +114,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const [users, setUsers] = useState<AdvancedOptions["users"]>([]);
const [editIcon, setEditIcon] = useState(true);
const [enteredAvailability, setEnteredAvailability] = useState<{
openingHours: OpeningHours[];
dateOverrides: DateOverride[];
openingHours: WorkingHours[];
dateOverrides: WorkingHours[];
}>();
const [showLocationModal, setShowLocationModal] = useState(false);
const [selectedTimeZone, setSelectedTimeZone] = useState("");

View File

@ -43,6 +43,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
avatar: true,
username: true,
timeZone: true,
hideBranding: true,
plan: true,
},
},
title: true,
@ -50,8 +52,15 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
description: true,
length: true,
schedulingType: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
periodDays: true,
periodCountCalendarDays: true,
minimumBookingNotice: true,
price: true,
currency: true,
timeZone: true,
},
},
},
@ -98,8 +107,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
profile: {
name: team.name,
slug: team.slug,
image: team.logo || null,
image: team.logo,
theme: null,
weekStart: "Sunday",
},
date: dateParam,
eventType: eventTypeObject,

View File

@ -0,0 +1,16 @@
/*
Warnings:
- The `periodType` column on the `EventType` table would be dropped and recreated. This will lead to data loss if there is data in the column.
*/
-- CreateEnum
CREATE TYPE "PeriodType" AS ENUM ('unlimited', 'rolling', 'range');
-- AlterTable
ALTER TABLE "EventType" RENAME COLUMN "periodType" to "old_periodType";
ALTER TABLE "EventType" ADD COLUMN "periodType" "PeriodType" NOT NULL DEFAULT E'unlimited';
UPDATE "EventType" SET "periodType" = "old_periodType"::"PeriodType";
ALTER TABLE "EventType" DROP COLUMN "old_periodType";

View File

@ -16,6 +16,12 @@ enum SchedulingType {
COLLECTIVE @map("collective")
}
enum PeriodType {
UNLIMITED @map("unlimited")
ROLLING @map("rolling")
RANGE @map("range")
}
model EventType {
id Int @id @default(autoincrement())
title String
@ -34,7 +40,7 @@ model EventType {
eventName String?
customInputs EventTypeCustomInput[]
timeZone String?
periodType String @default("unlimited") // unlimited | rolling | range
periodType PeriodType @default(UNLIMITED)
periodStartDate DateTime?
periodEndDate DateTime?
periodDays Int?

View File

@ -15,6 +15,7 @@ async function createUserAndEventType(opts: {
plan: UserPlan;
name: string;
completedOnboarding?: boolean;
timeZone?: string;
};
eventTypes: Array<
Prisma.EventTypeCreateInput & {
@ -268,6 +269,24 @@ async function main() {
],
});
await createUserAndEventType({
user: {
email: "usa@example.com",
password: "usa",
username: "usa",
name: "USA Timezone Example",
plan: "FREE",
timeZone: "America/Phoenix",
},
eventTypes: [
{
title: "30min",
slug: "30min",
length: 30,
},
],
});
const freeUserTeam = await createUserAndEventType({
user: {
email: "teamfree@example.com",

View File

@ -420,12 +420,29 @@ const loggedInViewerRouter = createProtectedRouter()
userId: user.id,
},
});
const schedule = availabilityQuery.reduce(
(schedule: Schedule, availability) => {
availability.days.forEach((day) => {
schedule[day].push({
start: new Date(new Date().toDateString() + " " + availability.startTime.toTimeString()),
end: new Date(new Date().toDateString() + " " + availability.endTime.toTimeString()),
start: new Date(
Date.UTC(
new Date().getUTCFullYear(),
new Date().getUTCMonth(),
new Date().getUTCDate(),
availability.startTime.getUTCHours(),
availability.startTime.getUTCMinutes()
)
),
end: new Date(
Date.UTC(
new Date().getUTCFullYear(),
new Date().getUTCMonth(),
new Date().getUTCDate(),
availability.endTime.getUTCHours(),
availability.endTime.getUTCMinutes()
)
),
});
});
return schedule;
@ -434,6 +451,7 @@ const loggedInViewerRouter = createProtectedRouter()
);
return {
schedule,
timeZone: user.timeZone,
};
},
})

View File

@ -0,0 +1,159 @@
import { expect, it } from "@jest/globals";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import MockDate from "mockdate";
import { getWorkingHours } from "@lib/availability";
dayjs.extend(utc);
dayjs.extend(timezone);
MockDate.set("2021-06-20T11:59:59Z");
it("correctly translates Availability (UTC+0) to UTC workingHours", async () => {
expect(
getWorkingHours({ timeZone: "GMT" }, [
{
days: [0],
startTime: new Date(Date.UTC(2021, 11, 16, 23)),
endTime: new Date(Date.UTC(2021, 11, 16, 23, 59)),
},
])
).toStrictEqual([
{
days: [0],
endTime: 1439,
startTime: 1380,
},
]);
});
it("correctly translates Availability in a positive UTC offset (Pacific/Auckland) to UTC workingHours", async () => {
// Take note that (Pacific/Auckland) is UTC+12 on 2021-06-20, NOT +13 like the other half of the year.
expect(
getWorkingHours({ timeZone: "Pacific/Auckland" }, [
{
days: [1],
startTime: new Date(Date.UTC(2021, 11, 16, 0)),
endTime: new Date(Date.UTC(2021, 11, 16, 23, 59)),
},
])
).toStrictEqual([
{
days: [1],
endTime: 719,
startTime: 0,
},
{
days: [0],
endTime: 1439,
startTime: 720, // 0 (midnight) - 12 * 60 (DST)
},
]);
});
it("correctly translates Availability in a negative UTC offset (Pacific/Midway) to UTC workingHours", async () => {
// Take note that (Pacific/Midway) is UTC-12 on 2021-06-20, NOT +13 like the other half of the year.
expect(
getWorkingHours({ timeZone: "Pacific/Midway" }, [
{
days: [1],
startTime: new Date(Date.UTC(2021, 11, 16, 0)),
endTime: new Date(Date.UTC(2021, 11, 16, 23, 59)),
},
])
).toStrictEqual([
{
days: [2],
endTime: 659,
startTime: 0,
},
{
days: [1],
endTime: 1439,
startTime: 660,
},
]);
});
it("can do the same with UTC offsets", async () => {
// Take note that (Pacific/Midway) is UTC-12 on 2021-06-20, NOT +13 like the other half of the year.
expect(
getWorkingHours({ utcOffset: dayjs().tz("Pacific/Midway").utcOffset() }, [
{
days: [1],
startTime: new Date(Date.UTC(2021, 11, 16, 0)),
endTime: new Date(Date.UTC(2021, 11, 16, 23, 59)),
},
])
).toStrictEqual([
{
days: [2],
endTime: 659,
startTime: 0,
},
{
days: [1],
endTime: 1439,
startTime: 660,
},
]);
});
it("can also shift UTC into other timeZones", async () => {
// UTC+0 time with 23:00 - 23:59 (Sunday) and 00:00 - 16:00 (Monday) when cast into UTC+1 should become 00:00 = 17:00 (Monday)
expect(
getWorkingHours({ utcOffset: -60 }, [
{
days: [0],
startTime: new Date(Date.UTC(2021, 11, 16, 23)),
endTime: new Date(Date.UTC(2021, 11, 16, 23, 59)),
},
{
days: [1],
startTime: new Date(Date.UTC(2021, 11, 17, 0)),
endTime: new Date(Date.UTC(2021, 11, 17, 16)),
},
])
).toStrictEqual([
// TODO: Maybe the desired result is 0-1020 as a single entry, but this requires some post-processing to merge. It may work as is so leaving this as now.
{
days: [1],
endTime: 59,
startTime: 0,
},
{
days: [1],
endTime: 1020,
startTime: 60,
},
]);
// And the other way around; UTC+0 time with 00:00 - 1:00 (Monday) and 21:00 - 24:00 (Sunday) when cast into UTC-1 should become 20:00 = 24:00 (Sunday)
expect(
getWorkingHours({ utcOffset: 60 }, [
{
days: [0],
startTime: new Date(Date.UTC(2021, 11, 16, 21)),
endTime: new Date(Date.UTC(2021, 11, 16, 23, 59)),
},
{
days: [1],
startTime: new Date(Date.UTC(2021, 11, 17, 0)),
endTime: new Date(Date.UTC(2021, 11, 17, 1)),
},
])
).toStrictEqual([
// TODO: Maybe the desired result is 1200-1439 as a single entry, but this requires some post-processing to merge. It may work as is so leaving this as now.
{
days: [0],
endTime: 1379,
startTime: 1200,
},
{
days: [0],
endTime: 1439,
startTime: 1380,
},
]);
});

View File

@ -4,6 +4,7 @@ import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import MockDate from "mockdate";
import { MINUTES_DAY_END, MINUTES_DAY_START } from "@lib/availability";
import getSlots from "@lib/slots";
dayjs.extend(utc);
@ -17,8 +18,14 @@ it("can fit 24 hourly slots for an empty day", async () => {
getSlots({
inviteeDate: dayjs().add(1, "day"),
frequency: 60,
workingHours: [{ days: Array.from(Array(7).keys()), startTime: 0, endTime: 1440 }],
organizerTimeZone: "Europe/London",
minimumBookingNotice: 0,
workingHours: [
{
days: Array.from(Array(7).keys()),
startTime: MINUTES_DAY_START,
endTime: MINUTES_DAY_END,
},
],
})
).toHaveLength(24);
});
@ -29,8 +36,14 @@ it.skip("only shows future booking slots on the same day", async () => {
getSlots({
inviteeDate: dayjs(),
frequency: 60,
workingHours: [{ days: Array.from(Array(7).keys()), startTime: 0, endTime: 1440 }],
organizerTimeZone: "GMT",
minimumBookingNotice: 0,
workingHours: [
{
days: Array.from(Array(7).keys()),
startTime: MINUTES_DAY_START,
endTime: MINUTES_DAY_END,
},
],
})
).toHaveLength(12);
});
@ -40,19 +53,32 @@ it("can cut off dates that due to invitee timezone differences fall on the next
getSlots({
inviteeDate: dayjs().tz("Europe/Amsterdam").startOf("day"), // time translation +01:00
frequency: 60,
workingHours: [{ days: [0], startTime: 1380, endTime: 1440 }],
organizerTimeZone: "Europe/London",
minimumBookingNotice: 0,
workingHours: [
{
days: [0],
startTime: 23 * 60, // 23h
endTime: MINUTES_DAY_END,
},
],
})
).toHaveLength(0);
});
it.skip("can cut off dates that due to invitee timezone differences fall on the previous day", async () => {
const workingHours = [
{
days: [0],
startTime: MINUTES_DAY_START,
endTime: 1 * 60, // 1h
},
];
expect(
getSlots({
inviteeDate: dayjs().startOf("day"), // time translation -01:00
frequency: 60,
workingHours: [{ days: [0], startTime: 0, endTime: 60 }],
organizerTimeZone: "Europe/London",
minimumBookingNotice: 0,
workingHours,
})
).toHaveLength(0);
});