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>pull/1187/head
parent
f3c95fa3de
commit
ffdf0b9217
|
@ -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>
|
||||
|
|
|
@ -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}`} />
|
||||
) : (
|
||||
|
|
|
@ -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
|
||||
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,
|
||||
}))
|
||||
)}
|
||||
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
|
||||
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}
|
||||
|
|
|
@ -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) => (
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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))}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,17 +46,11 @@ 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);
|
||||
})
|
||||
Promise.all<Slot[]>(
|
||||
users.map((user) => fetch(`/api/availability/${user.username}?${query}`).then(handleAvailableSlots))
|
||||
)
|
||||
).then((results) => {
|
||||
let loadedSlots: Slot[] = results[0];
|
||||
.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);
|
||||
|
@ -74,17 +62,17 @@ export const useSlots = (props: UseSlotsProps) => {
|
|||
switch (props.schedulingType) {
|
||||
// intersect by time, does not take into account eventLength (yet)
|
||||
case SchedulingType.COLLECTIVE:
|
||||
poolingMethod = (slots, compareWith) =>
|
||||
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, compareWith) => {
|
||||
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]);
|
||||
slots[match].users?.push(compare.users![0]);
|
||||
} else {
|
||||
slots.push(compare);
|
||||
}
|
||||
|
@ -94,23 +82,30 @@ export const useSlots = (props: UseSlotsProps) => {
|
|||
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);
|
||||
})
|
||||
.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
|
||||
|
|
160
lib/slots.ts
160
lib/slots.ts
|
@ -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;
|
||||
|
|
|
@ -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[] };
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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("");
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
||||
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?
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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 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);
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue