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:
parent
f3c95fa3de
commit
ffdf0b9217
23 changed files with 587 additions and 333 deletions
|
@ -11,11 +11,6 @@ import { useSlots } from "@lib/hooks/useSlots";
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
|
|
||||||
type AvailableTimesProps = {
|
type AvailableTimesProps = {
|
||||||
workingHours: {
|
|
||||||
days: number[];
|
|
||||||
startTime: number;
|
|
||||||
endTime: number;
|
|
||||||
}[];
|
|
||||||
timeFormat: string;
|
timeFormat: string;
|
||||||
minimumBookingNotice: number;
|
minimumBookingNotice: number;
|
||||||
eventTypeId: number;
|
eventTypeId: number;
|
||||||
|
@ -32,7 +27,6 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||||
eventLength,
|
eventLength,
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
minimumBookingNotice,
|
minimumBookingNotice,
|
||||||
workingHours,
|
|
||||||
timeFormat,
|
timeFormat,
|
||||||
users,
|
users,
|
||||||
schedulingType,
|
schedulingType,
|
||||||
|
@ -45,16 +39,15 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||||
date,
|
date,
|
||||||
eventLength,
|
eventLength,
|
||||||
schedulingType,
|
schedulingType,
|
||||||
workingHours,
|
|
||||||
users,
|
users,
|
||||||
minimumBookingNotice,
|
minimumBookingNotice,
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:-mb-5">
|
<div className="mt-8 text-center sm:pl-4 sm:mt-0 sm:w-1/3 md:-mb-5">
|
||||||
<div className="text-gray-600 font-light text-lg mb-4 text-left">
|
<div className="mb-4 text-lg font-light text-left text-gray-600">
|
||||||
<span className="w-1/2 dark:text-white text-gray-600">
|
<span className="w-1/2 text-gray-600 dark:text-white">
|
||||||
<strong>{t(date.format("dddd").toLowerCase())}</strong>
|
<strong>{t(date.format("dddd").toLowerCase())}</strong>
|
||||||
<span className="text-gray-500">
|
<span className="text-gray-500">
|
||||||
{date.format(", DD ")}
|
{date.format(", DD ")}
|
||||||
|
@ -91,7 +84,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||||
<div key={slot.time.format()}>
|
<div key={slot.time.format()}>
|
||||||
<Link href={bookingUrl}>
|
<Link href={bookingUrl}>
|
||||||
<a
|
<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">
|
data-testid="time">
|
||||||
{slot.time.format(timeFormat)}
|
{slot.time.format(timeFormat)}
|
||||||
</a>
|
</a>
|
||||||
|
@ -100,7 +93,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{!loading && !error && !slots.length && (
|
{!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>
|
<h1 className="my-6 text-xl text-black dark:text-white">{t("all_booked_today")}</h1>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -108,10 +101,10 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||||
{loading && <Loader />}
|
{loading && <Loader />}
|
||||||
|
|
||||||
{error && (
|
{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">
|
||||||
<div className="flex-shrink-0">
|
<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>
|
||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<p className="text-sm text-yellow-700">{t("slots_load_fail")}</p>
|
<p className="text-sm text-yellow-700">{t("slots_load_fail")}</p>
|
||||||
|
|
|
@ -1,40 +1,52 @@
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
|
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
|
||||||
|
import { PeriodType } from "@prisma/client";
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
// Then, include dayjs-business-time
|
// 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, useState } from "react";
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
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";
|
||||||
|
|
||||||
dayjs.extend(dayjsBusinessTime);
|
dayjs.extend(dayjsBusinessTime);
|
||||||
dayjs.extend(utc);
|
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({
|
function DatePicker({
|
||||||
weekStart,
|
weekStart,
|
||||||
onDatePicked,
|
onDatePicked,
|
||||||
workingHours,
|
workingHours,
|
||||||
organizerTimeZone,
|
|
||||||
eventLength,
|
eventLength,
|
||||||
date,
|
date,
|
||||||
periodType = "unlimited",
|
periodType = PeriodType.UNLIMITED,
|
||||||
periodStartDate,
|
periodStartDate,
|
||||||
periodEndDate,
|
periodEndDate,
|
||||||
periodDays,
|
periodDays,
|
||||||
periodCountCalendarDays,
|
periodCountCalendarDays,
|
||||||
minimumBookingNotice,
|
minimumBookingNotice,
|
||||||
}: any): JSX.Element {
|
}: DatePickerProps): JSX.Element {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [days, setDays] = useState<({ disabled: boolean; date: number } | null)[]>([]);
|
const [days, setDays] = useState<({ disabled: boolean; date: number } | null)[]>([]);
|
||||||
|
|
||||||
const [selectedMonth, setSelectedMonth] = useState<number | null>(
|
const [selectedMonth, setSelectedMonth] = useState<number>(
|
||||||
date
|
date
|
||||||
? periodType === "range"
|
? periodType === PeriodType.RANGE
|
||||||
? dayjs(periodStartDate).utcOffset(date.utcOffset()).month()
|
? dayjs(periodStartDate).utcOffset(date.utcOffset()).month()
|
||||||
: date.month()
|
: date.month()
|
||||||
: dayjs().month() /* High chance server is going to have the same month */
|
: dayjs().month() /* High chance server is going to have the same month */
|
||||||
|
@ -71,10 +83,13 @@ function DatePicker({
|
||||||
const isDisabled = (day: number) => {
|
const isDisabled = (day: number) => {
|
||||||
const date: Dayjs = inviteeDate().date(day);
|
const date: Dayjs = inviteeDate().date(day);
|
||||||
switch (periodType) {
|
switch (periodType) {
|
||||||
case "rolling": {
|
case PeriodType.ROLLING: {
|
||||||
|
if (!periodDays) {
|
||||||
|
throw new Error("PeriodType rolling requires periodDays");
|
||||||
|
}
|
||||||
const periodRollingEndDay = periodCountCalendarDays
|
const periodRollingEndDay = periodCountCalendarDays
|
||||||
? dayjs().tz(organizerTimeZone).add(periodDays, "days").endOf("day")
|
? dayjs.utc().add(periodDays, "days").endOf("day")
|
||||||
: dayjs().tz(organizerTimeZone).addBusinessTime(periodDays, "days").endOf("day");
|
: (dayjs.utc() as Dayjs).addBusinessTime(periodDays, "days").endOf("day");
|
||||||
return (
|
return (
|
||||||
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
|
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
|
||||||
date.endOf("day").isAfter(periodRollingEndDay) ||
|
date.endOf("day").isAfter(periodRollingEndDay) ||
|
||||||
|
@ -83,14 +98,13 @@ function DatePicker({
|
||||||
frequency: eventLength,
|
frequency: eventLength,
|
||||||
minimumBookingNotice,
|
minimumBookingNotice,
|
||||||
workingHours,
|
workingHours,
|
||||||
organizerTimeZone,
|
|
||||||
}).length
|
}).length
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case "range": {
|
case PeriodType.RANGE: {
|
||||||
const periodRangeStartDay = dayjs(periodStartDate).tz(organizerTimeZone).endOf("day");
|
const periodRangeStartDay = dayjs(periodStartDate).utc().endOf("day");
|
||||||
const periodRangeEndDay = dayjs(periodEndDate).tz(organizerTimeZone).endOf("day");
|
const periodRangeEndDay = dayjs(periodEndDate).utc().endOf("day");
|
||||||
return (
|
return (
|
||||||
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
|
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
|
||||||
date.endOf("day").isBefore(periodRangeStartDay) ||
|
date.endOf("day").isBefore(periodRangeStartDay) ||
|
||||||
|
@ -100,12 +114,11 @@ function DatePicker({
|
||||||
frequency: eventLength,
|
frequency: eventLength,
|
||||||
minimumBookingNotice,
|
minimumBookingNotice,
|
||||||
workingHours,
|
workingHours,
|
||||||
organizerTimeZone,
|
|
||||||
}).length
|
}).length
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case "unlimited":
|
case PeriodType.UNLIMITED:
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
|
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
|
||||||
|
@ -114,7 +127,6 @@ function DatePicker({
|
||||||
frequency: eventLength,
|
frequency: eventLength,
|
||||||
minimumBookingNotice,
|
minimumBookingNotice,
|
||||||
workingHours,
|
workingHours,
|
||||||
organizerTimeZone,
|
|
||||||
}).length
|
}).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: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")
|
: "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">
|
<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(inviteeDate().format("MMMM").toLowerCase())}
|
||||||
|
@ -155,18 +167,18 @@ function DatePicker({
|
||||||
)}
|
)}
|
||||||
disabled={typeof selectedMonth === "number" && selectedMonth <= dayjs().month()}
|
disabled={typeof selectedMonth === "number" && selectedMonth <= dayjs().month()}
|
||||||
data-testid="decrementMonth">
|
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>
|
||||||
<button className="group p-1" onClick={incrementMonth} data-testid="incrementMonth">
|
<button className="p-1 group" onClick={incrementMonth} data-testid="incrementMonth">
|
||||||
<ChevronRightIcon className="group-hover:text-black dark:group-hover:text-white w-5 h-5" />
|
<ChevronRightIcon className="w-5 h-5 group-hover:text-black dark:group-hover:text-white" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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"]
|
{["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||||
.sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0))
|
.sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0))
|
||||||
.map((weekDay) => (
|
.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)}
|
{t(weekDay.toLowerCase()).substring(0, 3)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
@ -178,7 +190,7 @@ function DatePicker({
|
||||||
style={{
|
style={{
|
||||||
paddingTop: "100%",
|
paddingTop: "100%",
|
||||||
}}
|
}}
|
||||||
className="w-full relative">
|
className="relative w-full">
|
||||||
{day === null ? (
|
{day === null ? (
|
||||||
<div key={`e-${idx}`} />
|
<div key={`e-${idx}`} />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
@ -93,8 +93,8 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
|
||||||
<HeadSeo
|
<HeadSeo
|
||||||
title={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title} | ${profile.name}`}
|
title={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title} | ${profile.name}`}
|
||||||
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
|
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
|
||||||
name={profile.name}
|
name={profile.name || undefined}
|
||||||
avatar={profile.image}
|
avatar={profile.image || undefined}
|
||||||
/>
|
/>
|
||||||
<CustomBranding val={profile.brandColor} />
|
<CustomBranding val={profile.brandColor} />
|
||||||
<div>
|
<div>
|
||||||
|
@ -109,14 +109,18 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
|
||||||
<div className="block p-4 sm:p-8 md:hidden">
|
<div className="block p-4 sm:p-8 md:hidden">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<AvatarGroup
|
<AvatarGroup
|
||||||
items={[{ image: profile.image, alt: profile.name }].concat(
|
items={
|
||||||
eventType.users
|
[
|
||||||
.filter((user) => user.name !== profile.name)
|
{ image: profile.image, alt: profile.name, title: profile.name },
|
||||||
.map((user) => ({
|
...eventType.users
|
||||||
title: user.name,
|
.filter((user) => user.name !== profile.name)
|
||||||
image: user.avatar,
|
.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}
|
size={9}
|
||||||
truncateAfter={5}
|
truncateAfter={5}
|
||||||
/>
|
/>
|
||||||
|
@ -153,14 +157,18 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
|
||||||
(selectedDate ? "sm:w-1/3" : "sm:w-1/2")
|
(selectedDate ? "sm:w-1/3" : "sm:w-1/2")
|
||||||
}>
|
}>
|
||||||
<AvatarGroup
|
<AvatarGroup
|
||||||
items={[{ image: profile.image, alt: profile.name }].concat(
|
items={
|
||||||
eventType.users
|
[
|
||||||
.filter((user) => user.name !== profile.name)
|
{ image: profile.image, alt: profile.name, title: profile.name },
|
||||||
.map((user) => ({
|
...eventType.users
|
||||||
title: user.name,
|
.filter((user) => user.name !== profile.name)
|
||||||
image: user.avatar,
|
.map((user) => ({
|
||||||
}))
|
title: user.name,
|
||||||
)}
|
alt: user.name,
|
||||||
|
image: user.avatar,
|
||||||
|
})),
|
||||||
|
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
|
||||||
|
}
|
||||||
size={10}
|
size={10}
|
||||||
truncateAfter={3}
|
truncateAfter={3}
|
||||||
/>
|
/>
|
||||||
|
@ -209,7 +217,6 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
|
||||||
|
|
||||||
{selectedDate && (
|
{selectedDate && (
|
||||||
<AvailableTimes
|
<AvailableTimes
|
||||||
workingHours={workingHours}
|
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
minimumBookingNotice={eventType.minimumBookingNotice}
|
minimumBookingNotice={eventType.minimumBookingNotice}
|
||||||
eventTypeId={eventType.id}
|
eventTypeId={eventType.id}
|
||||||
|
|
|
@ -12,7 +12,7 @@ export type AvatarGroupProps = {
|
||||||
items: {
|
items: {
|
||||||
image: string;
|
image: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
alt: string;
|
alt?: string;
|
||||||
}[];
|
}[];
|
||||||
className?: 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)}>
|
<ul className={classNames("flex -space-x-2 overflow-hidden", props.className)}>
|
||||||
{props.items.slice(0, props.truncateAfter).map((item, idx) => (
|
{props.items.slice(0, props.truncateAfter).map((item, idx) => (
|
||||||
<li key={idx} className="inline-block">
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
{/*props.items.length > props.truncateAfter && (
|
{/*props.items.length > props.truncateAfter && (
|
||||||
<li className="inline-block relative">
|
<li className="relative inline-block">
|
||||||
<Tooltip.Tooltip delayDuration="300">
|
<Tooltip.Tooltip delayDuration="300">
|
||||||
<Tooltip.TooltipTrigger className="cursor-default">
|
<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>
|
<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>
|
</Tooltip.TooltipTrigger>
|
||||||
{truncatedAvatars.length !== 0 && (
|
{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 />
|
<Tooltip.Arrow />
|
||||||
<ul>
|
<ul>
|
||||||
{truncatedAvatars.map((title) => (
|
{truncatedAvatars.map((title) => (
|
||||||
|
|
|
@ -7,7 +7,7 @@ import React, { useEffect, useState } from "react";
|
||||||
import TimezoneSelect, { ITimezoneOption } from "react-timezone-select";
|
import TimezoneSelect, { ITimezoneOption } from "react-timezone-select";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
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 { WeekdaySelect } from "./WeekdaySelect";
|
||||||
import SetTimesModal from "./modal/SetTimesModal";
|
import SetTimesModal from "./modal/SetTimesModal";
|
||||||
|
@ -19,7 +19,7 @@ type Props = {
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
availability: Availability[];
|
availability: Availability[];
|
||||||
setTimeZone: (timeZone: string) => void;
|
setTimeZone: (timeZone: string) => void;
|
||||||
setAvailability: (schedule: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] }) => void;
|
setAvailability: (schedule: { openingHours: WorkingHours[]; dateOverrides: WorkingHours[] }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { PlusIcon, TrashIcon } from "@heroicons/react/outline";
|
import { PlusIcon, TrashIcon } from "@heroicons/react/outline";
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs, ConfigType } from "dayjs";
|
||||||
import React, { useCallback, useState } from "react";
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import React from "react";
|
||||||
import { Controller, useFieldArray } from "react-hook-form";
|
import { Controller, useFieldArray } from "react-hook-form";
|
||||||
|
|
||||||
import { defaultDayRange } from "@lib/availability";
|
import { defaultDayRange } from "@lib/availability";
|
||||||
|
@ -11,6 +13,9 @@ import { TimeRange } from "@lib/types/schedule";
|
||||||
import Button from "@components/ui/Button";
|
import Button from "@components/ui/Button";
|
||||||
import Select from "@components/ui/form/Select";
|
import Select from "@components/ui/form/Select";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
/** Begin Time Increments For Select */
|
/** Begin Time Increments For Select */
|
||||||
const increment = 15;
|
const increment = 15;
|
||||||
/**
|
/**
|
||||||
|
@ -31,30 +36,17 @@ const TIMES = (() => {
|
||||||
})();
|
})();
|
||||||
/** End Time Increments For Select */
|
/** End Time Increments For Select */
|
||||||
|
|
||||||
type Option = {
|
|
||||||
readonly label: string;
|
|
||||||
readonly value: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TimeRangeFieldProps = {
|
type TimeRangeFieldProps = {
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TimeRangeField = ({ name }: TimeRangeFieldProps) => {
|
const TimeRangeField = ({ name }: TimeRangeFieldProps) => {
|
||||||
// Lazy-loaded options, otherwise adding a field has a noticable redraw delay.
|
const getOption = (time: ConfigType) => ({
|
||||||
const [options, setOptions] = useState<Option[]>([]);
|
value: dayjs(time).utc(true).toDate().valueOf(),
|
||||||
|
label: dayjs(time).toDate().toLocaleTimeString("nl-NL", { minute: "numeric", hour: "numeric" }),
|
||||||
const getOption = (time: Date) => ({
|
|
||||||
value: time.valueOf(),
|
|
||||||
label: time.toLocaleTimeString("nl-NL", { minute: "numeric", hour: "numeric" }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const timeOptions = useCallback((offsetOrLimit: { offset?: number; limit?: number } = {}) => {
|
const timeOptions = TIMES.map((t) => getOption(t));
|
||||||
const { limit, offset } = offsetOrLimit;
|
|
||||||
return TIMES.filter((time) => (!limit || time.isBefore(limit)) && (!offset || time.isAfter(offset))).map(
|
|
||||||
(t) => getOption(t.toDate())
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -63,10 +55,10 @@ const TimeRangeField = ({ name }: TimeRangeFieldProps) => {
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange, value } }) => (
|
||||||
<Select
|
<Select
|
||||||
className="w-[6rem]"
|
className="w-[6rem]"
|
||||||
options={options}
|
options={timeOptions}
|
||||||
onFocus={() => setOptions(timeOptions())}
|
value={timeOptions.filter(function (option) {
|
||||||
onBlur={() => setOptions([])}
|
return option.value === getOption(value).value;
|
||||||
defaultValue={getOption(value)}
|
})}
|
||||||
onChange={(option) => onChange(new Date(option?.value as number))}
|
onChange={(option) => onChange(new Date(option?.value as number))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -77,10 +69,10 @@ const TimeRangeField = ({ name }: TimeRangeFieldProps) => {
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange, value } }) => (
|
||||||
<Select
|
<Select
|
||||||
className="w-[6rem]"
|
className="w-[6rem]"
|
||||||
options={options}
|
options={timeOptions}
|
||||||
onFocus={() => setOptions(timeOptions())}
|
value={timeOptions.filter(function (option) {
|
||||||
onBlur={() => setOptions([])}
|
return option.value === getOption(value).value;
|
||||||
defaultValue={getOption(value)}
|
})}
|
||||||
onChange={(option) => onChange(new Date(option?.value as number))}
|
onChange={(option) => onChange(new Date(option?.value as number))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,11 +1,18 @@
|
||||||
import { Availability } from "@prisma/client";
|
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
|
// sets the desired time in current date, needs to be current date for proper DST translation
|
||||||
export const defaultDayRange: TimeRange = {
|
export const defaultDayRange: TimeRange = {
|
||||||
start: new Date(new Date().setHours(9, 0, 0, 0)),
|
start: new Date(new Date().setUTCHours(9, 0, 0, 0)),
|
||||||
end: new Date(new Date().setHours(17, 0, 0, 0)),
|
end: new Date(new Date().setUTCHours(17, 0, 0, 0)),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_SCHEDULE: Schedule = [
|
export const DEFAULT_SCHEDULE: Schedule = [
|
||||||
|
@ -45,3 +52,75 @@ export function getAvailabilityFromSchedule(schedule: Schedule): Availability[]
|
||||||
return availability;
|
return availability;
|
||||||
}, [] as 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;
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Availability, SchedulingType } from "@prisma/client";
|
import { SchedulingType } from "@prisma/client";
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import isBetween from "dayjs/plugin/isBetween";
|
import isBetween from "dayjs/plugin/isBetween";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
|
@ -6,16 +6,15 @@ import { stringify } from "querystring";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import getSlots from "@lib/slots";
|
import getSlots from "@lib/slots";
|
||||||
|
import { TimeRange, WorkingHours } from "@lib/types/schedule";
|
||||||
import { FreeBusyTime } from "@components/ui/Schedule/Schedule";
|
|
||||||
|
|
||||||
dayjs.extend(isBetween);
|
dayjs.extend(isBetween);
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
|
||||||
type AvailabilityUserResponse = {
|
type AvailabilityUserResponse = {
|
||||||
busy: FreeBusyTime;
|
busy: TimeRange[];
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
workingHours: Availability[];
|
workingHours: WorkingHours[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type Slot = {
|
type Slot = {
|
||||||
|
@ -28,11 +27,6 @@ type UseSlotsProps = {
|
||||||
eventTypeId: number;
|
eventTypeId: number;
|
||||||
minimumBookingNotice?: number;
|
minimumBookingNotice?: number;
|
||||||
date: Dayjs;
|
date: Dayjs;
|
||||||
workingHours: {
|
|
||||||
days: number[];
|
|
||||||
startTime: number;
|
|
||||||
endTime: number;
|
|
||||||
}[];
|
|
||||||
users: { username: string | null }[];
|
users: { username: string | null }[];
|
||||||
schedulingType: SchedulingType | null;
|
schedulingType: SchedulingType | null;
|
||||||
};
|
};
|
||||||
|
@ -52,65 +46,66 @@ export const useSlots = (props: UseSlotsProps) => {
|
||||||
const dateTo = date.endOf("day").format();
|
const dateTo = date.endOf("day").format();
|
||||||
const query = stringify({ dateFrom, dateTo, eventTypeId });
|
const query = stringify({ dateFrom, dateTo, eventTypeId });
|
||||||
|
|
||||||
Promise.all(
|
Promise.all<Slot[]>(
|
||||||
users.map((user) =>
|
users.map((user) => fetch(`/api/availability/${user.username}?${query}`).then(handleAvailableSlots))
|
||||||
fetch(`/api/availability/${user.username}?${query}`)
|
)
|
||||||
.then(handleAvailableSlots)
|
.then((results) => {
|
||||||
.catch((e) => {
|
let loadedSlots: Slot[] = results[0] || [];
|
||||||
console.error(e);
|
if (results.length === 1) {
|
||||||
setError(e);
|
loadedSlots = loadedSlots?.sort((a, b) => (a.time.isAfter(b.time) ? 1 : -1));
|
||||||
})
|
setSlots(loadedSlots);
|
||||||
)
|
setLoading(false);
|
||||||
).then((results) => {
|
return;
|
||||||
let loadedSlots: Slot[] = results[0];
|
}
|
||||||
if (results.length === 1) {
|
|
||||||
loadedSlots = loadedSlots?.sort((a, b) => (a.time.isAfter(b.time) ? 1 : -1));
|
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);
|
setSlots(loadedSlots);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
})
|
||||||
}
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
let poolingMethod;
|
setError(e);
|
||||||
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);
|
|
||||||
});
|
|
||||||
}, [date]);
|
}, [date]);
|
||||||
|
|
||||||
const handleAvailableSlots = async (res) => {
|
const handleAvailableSlots = async (res: Response) => {
|
||||||
const responseBody: AvailabilityUserResponse = await res.json();
|
const responseBody: AvailabilityUserResponse = await res.json();
|
||||||
const times = getSlots({
|
const times = getSlots({
|
||||||
frequency: eventLength,
|
frequency: eventLength,
|
||||||
inviteeDate: date,
|
inviteeDate: date,
|
||||||
workingHours: responseBody.workingHours,
|
workingHours: responseBody.workingHours,
|
||||||
minimumBookingNotice,
|
minimumBookingNotice,
|
||||||
organizerTimeZone: responseBody.timeZone,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for conflicts
|
// Check for conflicts
|
||||||
|
|
160
lib/slots.ts
160
lib/slots.ts
|
@ -1,137 +1,63 @@
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import isBetween from "dayjs/plugin/isBetween";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
|
|
||||||
|
import { getWorkingHours } from "./availability";
|
||||||
|
import { WorkingHours } from "./types/schedule";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(isBetween);
|
||||||
|
|
||||||
type WorkingHour = {
|
export type GetSlots = {
|
||||||
days: number[];
|
|
||||||
startTime: number;
|
|
||||||
endTime: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GetSlots = {
|
|
||||||
inviteeDate: Dayjs;
|
inviteeDate: Dayjs;
|
||||||
frequency: number;
|
frequency: number;
|
||||||
workingHours: WorkingHour[];
|
workingHours: WorkingHours[];
|
||||||
minimumBookingNotice?: number;
|
minimumBookingNotice: number;
|
||||||
organizerTimeZone: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type Boundary = {
|
const getMinuteOffset = (date: Dayjs, step: number) => {
|
||||||
lowerBound: number;
|
// Diffs the current time with the given date and iff same day; (handled by 1440) - return difference; otherwise 0
|
||||||
upperBound: number;
|
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 getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours }: GetSlots) => {
|
||||||
|
// current date in invitee tz
|
||||||
const intersectBoundary = (a: Boundary, b: Boundary) => {
|
const startDate = dayjs(inviteeDate).add(minimumBookingNotice, "minutes"); // + minimum notice period
|
||||||
if (a.upperBound < b.lowerBound || a.lowerBound > b.upperBound) {
|
// checks if the start date is in the past
|
||||||
return;
|
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 localWorkingHours = getWorkingHours(
|
||||||
const getOverlaps = (inviteeBoundary: Boundary, boundaries: Boundary[]) =>
|
{ utcOffset: -inviteeDate.utcOffset() },
|
||||||
boundaries.map((boundary) => intersectBoundary(inviteeBoundary, boundary)).filter(Boolean);
|
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[] = [];
|
const slots: Dayjs[] = [];
|
||||||
for (let minutes = 0; lowerBound + minutes <= upperBound - frequency; minutes += frequency) {
|
for (let minutes = getMinuteOffset(inviteeDate, frequency); minutes < 1440; minutes += frequency) {
|
||||||
slots.push(
|
const slot = inviteeDate.startOf("day").add(minutes, "minutes");
|
||||||
dayjs
|
// add slots to available slots if it is found to be between the start and end time of the checked working hours.
|
||||||
.utc()
|
if (
|
||||||
.startOf("d")
|
localWorkingHours.some((hours) =>
|
||||||
.add(lowerBound + minutes, "minutes")
|
slot.isBetween(
|
||||||
);
|
inviteeDate.startOf("day").add(hours.startTime, "minutes"),
|
||||||
|
inviteeDate.startOf("day").add(hours.endTime, "minutes"),
|
||||||
|
null,
|
||||||
|
"[)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
slots.push(slot);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return slots;
|
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;
|
export default getSlots;
|
||||||
|
|
|
@ -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">;
|
import { WorkingHours } from "./schedule";
|
||||||
export type DateOverride = Pick<Availability, "date" | "startTime" | "endTime">;
|
|
||||||
|
|
||||||
export type AdvancedOptions = {
|
export type AdvancedOptions = {
|
||||||
eventName?: string;
|
eventName?: string;
|
||||||
|
@ -21,7 +20,7 @@ export type AdvancedOptions = {
|
||||||
label: string;
|
label: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
}[];
|
}[];
|
||||||
availability?: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] };
|
availability?: { openingHours: WorkingHours[]; dateOverrides: WorkingHours[] };
|
||||||
customInputs?: EventTypeCustomInput[];
|
customInputs?: EventTypeCustomInput[];
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
|
@ -58,5 +57,5 @@ export type EventTypeInput = AdvancedOptions & {
|
||||||
locations: unknown;
|
locations: unknown;
|
||||||
customInputs: EventTypeCustomInput[];
|
customInputs: EventTypeCustomInput[];
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
availability?: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] };
|
availability?: { openingHours: WorkingHours[]; dateOverrides: WorkingHours[] };
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,3 +4,15 @@ export type TimeRange = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Schedule = 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;
|
||||||
|
};
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { Prisma } from "@prisma/client";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
|
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
|
import { getWorkingHours } from "@lib/availability";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
|
@ -42,6 +43,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
periodCountCalendarDays: true,
|
periodCountCalendarDays: true,
|
||||||
schedulingType: true,
|
schedulingType: true,
|
||||||
minimumBookingNotice: true,
|
minimumBookingNotice: true,
|
||||||
|
timeZone: true,
|
||||||
users: {
|
users: {
|
||||||
select: {
|
select: {
|
||||||
avatar: true,
|
avatar: true,
|
||||||
|
@ -49,6 +51,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
username: true,
|
username: true,
|
||||||
hideBranding: true,
|
hideBranding: true,
|
||||||
plan: true,
|
plan: true,
|
||||||
|
timeZone: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -120,6 +123,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
username: user.username,
|
username: user.username,
|
||||||
hideBranding: user.hideBranding,
|
hideBranding: user.hideBranding,
|
||||||
plan: user.plan,
|
plan: user.plan,
|
||||||
|
timeZone: user.timeZone,
|
||||||
});
|
});
|
||||||
user.eventTypes.push(eventTypeBackwardsCompat);
|
user.eventTypes.push(eventTypeBackwardsCompat);
|
||||||
}
|
}
|
||||||
|
@ -156,33 +160,19 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
} as const;
|
} 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, {
|
const eventTypeObject = Object.assign({}, eventType, {
|
||||||
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
||||||
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const workingHours = getWorkingHours(
|
||||||
|
{
|
||||||
|
timeZone: user.timeZone,
|
||||||
|
},
|
||||||
|
eventType.availability.length ? eventType.availability : user.availability
|
||||||
|
);
|
||||||
|
|
||||||
eventTypeObject.availability = [];
|
eventTypeObject.availability = [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -6,6 +6,7 @@ import utc from "dayjs/plugin/utc";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
|
import { getWorkingHours } from "@lib/availability";
|
||||||
import { getBusyCalendarTimes } from "@lib/calendarClient";
|
import { getBusyCalendarTimes } from "@lib/calendarClient";
|
||||||
import prisma from "@lib/prisma";
|
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 timeZone = eventType?.timeZone || currentUser.timeZone;
|
||||||
const workingHours = eventType?.availability.length ? eventType.availability : currentUser.availability;
|
const workingHours = getWorkingHours(
|
||||||
|
{ timeZone },
|
||||||
// FIXME: Currently the organizer timezone is used for the logic
|
eventType?.availability.length ? eventType.availability : currentUser.availability
|
||||||
// refactor to be organizerTimezone unaware, use UTC instead.
|
);
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
busy: bufferedBusyTimes,
|
busy: bufferedBusyTimes,
|
||||||
timeZone,
|
timeZone,
|
||||||
workingHours: 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(),
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
import prisma from "@lib/prisma";
|
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) {
|
function handleCustomInputs(customInputs: EventTypeCustomInput[], eventTypeId: number) {
|
||||||
if (!customInputs || !customInputs?.length) return undefined;
|
if (!customInputs || !customInputs?.length) return undefined;
|
||||||
|
@ -112,7 +116,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
locations: req.body.locations,
|
locations: req.body.locations,
|
||||||
eventName: req.body.eventName,
|
eventName: req.body.eventName,
|
||||||
customInputs: handleCustomInputs(req.body.customInputs as EventTypeCustomInput[], req.body.id),
|
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,
|
periodDays: req.body.periodDays,
|
||||||
periodStartDate: req.body.periodStartDate,
|
periodStartDate: req.body.periodStartDate,
|
||||||
periodEndDate: req.body.periodEndDate,
|
periodEndDate: req.body.periodEndDate,
|
||||||
|
@ -161,7 +165,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.body.availability) {
|
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 overrides = req.body.availability.dateOverrides || [];
|
||||||
|
|
||||||
const eventTypeId = +req.body.id;
|
const eventTypeId = +req.body.id;
|
||||||
|
|
|
@ -23,7 +23,7 @@ export function AvailabilityForm(props: inferQueryOutput<"viewer.availability">)
|
||||||
const createSchedule = async ({ schedule }: FormValues) => {
|
const createSchedule = async ({ schedule }: FormValues) => {
|
||||||
const res = await fetch(`/api/schedule`, {
|
const res = await fetch(`/api/schedule`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ schedule }),
|
body: JSON.stringify({ schedule, timeZone: props.timeZone }),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
@ -42,6 +42,7 @@ export function AvailabilityForm(props: inferQueryOutput<"viewer.availability">)
|
||||||
schedule: props.schedule || DEFAULT_SCHEDULE,
|
schedule: props.schedule || DEFAULT_SCHEDULE,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<Form
|
<Form
|
||||||
|
|
|
@ -44,8 +44,9 @@ import updateEventType from "@lib/mutations/event-types/update-event-type";
|
||||||
import showToast from "@lib/notification";
|
import showToast from "@lib/notification";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { defaultAvatarSrc } from "@lib/profile";
|
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 { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
import { WorkingHours } from "@lib/types/schedule";
|
||||||
|
|
||||||
import { Dialog, DialogContent, DialogTrigger } from "@components/Dialog";
|
import { Dialog, DialogContent, DialogTrigger } from "@components/Dialog";
|
||||||
import Shell from "@components/Shell";
|
import Shell from "@components/Shell";
|
||||||
|
@ -113,8 +114,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
const [users, setUsers] = useState<AdvancedOptions["users"]>([]);
|
const [users, setUsers] = useState<AdvancedOptions["users"]>([]);
|
||||||
const [editIcon, setEditIcon] = useState(true);
|
const [editIcon, setEditIcon] = useState(true);
|
||||||
const [enteredAvailability, setEnteredAvailability] = useState<{
|
const [enteredAvailability, setEnteredAvailability] = useState<{
|
||||||
openingHours: OpeningHours[];
|
openingHours: WorkingHours[];
|
||||||
dateOverrides: DateOverride[];
|
dateOverrides: WorkingHours[];
|
||||||
}>();
|
}>();
|
||||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||||
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||||
|
|
|
@ -43,6 +43,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
avatar: true,
|
avatar: true,
|
||||||
username: true,
|
username: true,
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
|
hideBranding: true,
|
||||||
|
plan: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
title: true,
|
title: true,
|
||||||
|
@ -50,8 +52,15 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
description: true,
|
description: true,
|
||||||
length: true,
|
length: true,
|
||||||
schedulingType: true,
|
schedulingType: true,
|
||||||
|
periodType: true,
|
||||||
periodStartDate: true,
|
periodStartDate: true,
|
||||||
periodEndDate: 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: {
|
profile: {
|
||||||
name: team.name,
|
name: team.name,
|
||||||
slug: team.slug,
|
slug: team.slug,
|
||||||
image: team.logo || null,
|
image: team.logo,
|
||||||
theme: null,
|
theme: null,
|
||||||
|
weekStart: "Sunday",
|
||||||
},
|
},
|
||||||
date: dateParam,
|
date: dateParam,
|
||||||
eventType: eventTypeObject,
|
eventType: eventTypeObject,
|
||||||
|
|
|
@ -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";
|
|
@ -16,6 +16,12 @@ enum SchedulingType {
|
||||||
COLLECTIVE @map("collective")
|
COLLECTIVE @map("collective")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PeriodType {
|
||||||
|
UNLIMITED @map("unlimited")
|
||||||
|
ROLLING @map("rolling")
|
||||||
|
RANGE @map("range")
|
||||||
|
}
|
||||||
|
|
||||||
model EventType {
|
model EventType {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
title String
|
title String
|
||||||
|
@ -34,7 +40,7 @@ model EventType {
|
||||||
eventName String?
|
eventName String?
|
||||||
customInputs EventTypeCustomInput[]
|
customInputs EventTypeCustomInput[]
|
||||||
timeZone String?
|
timeZone String?
|
||||||
periodType String @default("unlimited") // unlimited | rolling | range
|
periodType PeriodType @default(UNLIMITED)
|
||||||
periodStartDate DateTime?
|
periodStartDate DateTime?
|
||||||
periodEndDate DateTime?
|
periodEndDate DateTime?
|
||||||
periodDays Int?
|
periodDays Int?
|
||||||
|
|
|
@ -15,6 +15,7 @@ async function createUserAndEventType(opts: {
|
||||||
plan: UserPlan;
|
plan: UserPlan;
|
||||||
name: string;
|
name: string;
|
||||||
completedOnboarding?: boolean;
|
completedOnboarding?: boolean;
|
||||||
|
timeZone?: string;
|
||||||
};
|
};
|
||||||
eventTypes: Array<
|
eventTypes: Array<
|
||||||
Prisma.EventTypeCreateInput & {
|
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({
|
const freeUserTeam = await createUserAndEventType({
|
||||||
user: {
|
user: {
|
||||||
email: "teamfree@example.com",
|
email: "teamfree@example.com",
|
||||||
|
|
|
@ -420,12 +420,29 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const schedule = availabilityQuery.reduce(
|
const schedule = availabilityQuery.reduce(
|
||||||
(schedule: Schedule, availability) => {
|
(schedule: Schedule, availability) => {
|
||||||
availability.days.forEach((day) => {
|
availability.days.forEach((day) => {
|
||||||
schedule[day].push({
|
schedule[day].push({
|
||||||
start: new Date(new Date().toDateString() + " " + availability.startTime.toTimeString()),
|
start: new Date(
|
||||||
end: new Date(new Date().toDateString() + " " + availability.endTime.toTimeString()),
|
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;
|
return schedule;
|
||||||
|
@ -434,6 +451,7 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
schedule,
|
schedule,
|
||||||
|
timeZone: user.timeZone,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
159
test/lib/getWorkingHours.test.ts
Normal file
159
test/lib/getWorkingHours.test.ts
Normal 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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
|
@ -4,6 +4,7 @@ import timezone from "dayjs/plugin/timezone";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import MockDate from "mockdate";
|
import MockDate from "mockdate";
|
||||||
|
|
||||||
|
import { MINUTES_DAY_END, MINUTES_DAY_START } from "@lib/availability";
|
||||||
import getSlots from "@lib/slots";
|
import getSlots from "@lib/slots";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
@ -17,8 +18,14 @@ it("can fit 24 hourly slots for an empty day", async () => {
|
||||||
getSlots({
|
getSlots({
|
||||||
inviteeDate: dayjs().add(1, "day"),
|
inviteeDate: dayjs().add(1, "day"),
|
||||||
frequency: 60,
|
frequency: 60,
|
||||||
workingHours: [{ days: Array.from(Array(7).keys()), startTime: 0, endTime: 1440 }],
|
minimumBookingNotice: 0,
|
||||||
organizerTimeZone: "Europe/London",
|
workingHours: [
|
||||||
|
{
|
||||||
|
days: Array.from(Array(7).keys()),
|
||||||
|
startTime: MINUTES_DAY_START,
|
||||||
|
endTime: MINUTES_DAY_END,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
).toHaveLength(24);
|
).toHaveLength(24);
|
||||||
});
|
});
|
||||||
|
@ -29,8 +36,14 @@ it.skip("only shows future booking slots on the same day", async () => {
|
||||||
getSlots({
|
getSlots({
|
||||||
inviteeDate: dayjs(),
|
inviteeDate: dayjs(),
|
||||||
frequency: 60,
|
frequency: 60,
|
||||||
workingHours: [{ days: Array.from(Array(7).keys()), startTime: 0, endTime: 1440 }],
|
minimumBookingNotice: 0,
|
||||||
organizerTimeZone: "GMT",
|
workingHours: [
|
||||||
|
{
|
||||||
|
days: Array.from(Array(7).keys()),
|
||||||
|
startTime: MINUTES_DAY_START,
|
||||||
|
endTime: MINUTES_DAY_END,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
).toHaveLength(12);
|
).toHaveLength(12);
|
||||||
});
|
});
|
||||||
|
@ -40,19 +53,32 @@ it("can cut off dates that due to invitee timezone differences fall on the next
|
||||||
getSlots({
|
getSlots({
|
||||||
inviteeDate: dayjs().tz("Europe/Amsterdam").startOf("day"), // time translation +01:00
|
inviteeDate: dayjs().tz("Europe/Amsterdam").startOf("day"), // time translation +01:00
|
||||||
frequency: 60,
|
frequency: 60,
|
||||||
workingHours: [{ days: [0], startTime: 1380, endTime: 1440 }],
|
minimumBookingNotice: 0,
|
||||||
organizerTimeZone: "Europe/London",
|
workingHours: [
|
||||||
|
{
|
||||||
|
days: [0],
|
||||||
|
startTime: 23 * 60, // 23h
|
||||||
|
endTime: MINUTES_DAY_END,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
).toHaveLength(0);
|
).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.skip("can cut off dates that due to invitee timezone differences fall on the previous day", async () => {
|
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(
|
expect(
|
||||||
getSlots({
|
getSlots({
|
||||||
inviteeDate: dayjs().startOf("day"), // time translation -01:00
|
inviteeDate: dayjs().startOf("day"), // time translation -01:00
|
||||||
frequency: 60,
|
frequency: 60,
|
||||||
workingHours: [{ days: [0], startTime: 0, endTime: 60 }],
|
minimumBookingNotice: 0,
|
||||||
organizerTimeZone: "Europe/London",
|
workingHours,
|
||||||
})
|
})
|
||||||
).toHaveLength(0);
|
).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue