diff --git a/components/booking/AvailableTimes.tsx b/components/booking/AvailableTimes.tsx index 1fc178cba4..608d0da569 100644 --- a/components/booking/AvailableTimes.tsx +++ b/components/booking/AvailableTimes.tsx @@ -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 = ({ eventLength, eventTypeId, minimumBookingNotice, - workingHours, timeFormat, users, schedulingType, @@ -45,16 +39,15 @@ const AvailableTimes: FC = ({ date, eventLength, schedulingType, - workingHours, users, minimumBookingNotice, eventTypeId, }); return ( -
-
- +
+
+ {t(date.format("dddd").toLowerCase())} {date.format(", DD ")} @@ -91,7 +84,7 @@ const AvailableTimes: FC = ({
{slot.time.format(timeFormat)} @@ -100,7 +93,7 @@ const AvailableTimes: FC = ({ ); })} {!loading && !error && !slots.length && ( -
+

{t("all_booked_today")}

)} @@ -108,10 +101,10 @@ const AvailableTimes: FC = ({ {loading && } {error && ( -
+
-

{t("slots_load_fail")}

diff --git a/components/booking/DatePicker.tsx b/components/booking/DatePicker.tsx index 53ce7630e7..bc207579a2 100644 --- a/components/booking/DatePicker.tsx +++ b/components/booking/DatePicker.tsx @@ -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( + const [selectedMonth, setSelectedMonth] = useState( 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") }> -
+
{t(inviteeDate().format("MMMM").toLowerCase())} @@ -155,18 +167,18 @@ function DatePicker({ )} disabled={typeof selectedMonth === "number" && selectedMonth <= dayjs().month()} data-testid="decrementMonth"> - + -
-
+
{["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] .sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0)) .map((weekDay) => ( -
+
{t(weekDay.toLowerCase()).substring(0, 3)}
))} @@ -178,7 +190,7 @@ function DatePicker({ style={{ paddingTop: "100%", }} - className="w-full relative"> + className="relative w-full"> {day === null ? (
) : ( diff --git a/components/booking/pages/AvailabilityPage.tsx b/components/booking/pages/AvailabilityPage.tsx index 6d99f2a815..5500710f62 100644 --- a/components/booking/pages/AvailabilityPage.tsx +++ b/components/booking/pages/AvailabilityPage.tsx @@ -93,8 +93,8 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
@@ -109,14 +109,18 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
user.name !== profile.name) - .map((user) => ({ - title: user.name, - image: user.avatar, - })) - )} + items={ + [ + { image: profile.image, alt: profile.name, title: profile.name }, + ...eventType.users + .filter((user) => user.name !== profile.name) + .map((user) => ({ + title: user.name, + image: user.avatar || undefined, + alt: user.name || undefined, + })), + ].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[] + } size={9} truncateAfter={5} /> @@ -153,14 +157,18 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => { (selectedDate ? "sm:w-1/3" : "sm:w-1/2") }> user.name !== profile.name) - .map((user) => ({ - title: user.name, - image: user.avatar, - })) - )} + items={ + [ + { image: profile.image, alt: profile.name, title: profile.name }, + ...eventType.users + .filter((user) => user.name !== profile.name) + .map((user) => ({ + title: user.name, + alt: user.name, + image: user.avatar, + })), + ].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[] + } size={10} truncateAfter={3} /> @@ -209,7 +217,6 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => { {selectedDate && ( {props.items.slice(0, props.truncateAfter).map((item, idx) => (
  • - +
  • ))} {/*props.items.length > props.truncateAfter && ( -
  • +
  • +1 {truncatedAvatars.length !== 0 && ( - +
      {truncatedAvatars.map((title) => ( diff --git a/components/ui/Scheduler.tsx b/components/ui/Scheduler.tsx index 0d5777a075..39fb4c54dd 100644 --- a/components/ui/Scheduler.tsx +++ b/components/ui/Scheduler.tsx @@ -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; }; /** diff --git a/components/ui/form/Schedule.tsx b/components/ui/form/Schedule.tsx index a07668254a..f5f0eddef1 100644 --- a/components/ui/form/Schedule.tsx +++ b/components/ui/form/Schedule.tsx @@ -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([]); - - 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 } }) => ( 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))} /> )} diff --git a/lib/availability.ts b/lib/availability.ts index a011601a2a..1009f3a8be 100644 --- a/lib/availability.ts +++ b/lib/availability.ts @@ -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; +} diff --git a/lib/hooks/useSlots.ts b/lib/hooks/useSlots.ts index ab4b6fd37c..7e02af47c1 100644 --- a/lib/hooks/useSlots.ts +++ b/lib/hooks/useSlots.ts @@ -1,4 +1,4 @@ -import { Availability, SchedulingType } from "@prisma/client"; +import { SchedulingType } from "@prisma/client"; import dayjs, { Dayjs } from "dayjs"; import isBetween from "dayjs/plugin/isBetween"; import utc from "dayjs/plugin/utc"; @@ -6,16 +6,15 @@ import { stringify } from "querystring"; import { useEffect, useState } from "react"; import getSlots from "@lib/slots"; - -import { FreeBusyTime } from "@components/ui/Schedule/Schedule"; +import { TimeRange, WorkingHours } from "@lib/types/schedule"; dayjs.extend(isBetween); dayjs.extend(utc); type AvailabilityUserResponse = { - busy: FreeBusyTime; + busy: TimeRange[]; timeZone: string; - workingHours: Availability[]; + workingHours: WorkingHours[]; }; type Slot = { @@ -28,11 +27,6 @@ type UseSlotsProps = { eventTypeId: number; minimumBookingNotice?: number; date: Dayjs; - workingHours: { - days: number[]; - startTime: number; - endTime: number; - }[]; users: { username: string | null }[]; schedulingType: SchedulingType | null; }; @@ -52,65 +46,66 @@ export const useSlots = (props: UseSlotsProps) => { const dateTo = date.endOf("day").format(); const query = stringify({ dateFrom, dateTo, eventTypeId }); - Promise.all( - users.map((user) => - fetch(`/api/availability/${user.username}?${query}`) - .then(handleAvailableSlots) - .catch((e) => { - console.error(e); - setError(e); - }) - ) - ).then((results) => { - let loadedSlots: Slot[] = results[0]; - if (results.length === 1) { - loadedSlots = loadedSlots?.sort((a, b) => (a.time.isAfter(b.time) ? 1 : -1)); + Promise.all( + users.map((user) => fetch(`/api/availability/${user.username}?${query}`).then(handleAvailableSlots)) + ) + .then((results) => { + let loadedSlots: Slot[] = results[0] || []; + if (results.length === 1) { + loadedSlots = loadedSlots?.sort((a, b) => (a.time.isAfter(b.time) ? 1 : -1)); + setSlots(loadedSlots); + setLoading(false); + return; + } + + let poolingMethod; + switch (props.schedulingType) { + // intersect by time, does not take into account eventLength (yet) + case SchedulingType.COLLECTIVE: + poolingMethod = (slots: Slot[], compareWith: Slot[]) => + slots.filter((slot) => compareWith.some((compare) => compare.time.isSame(slot.time))); + break; + case SchedulingType.ROUND_ROBIN: + // TODO: Create a Reservation (lock this slot for X minutes) + // this will make the following code redundant + poolingMethod = (slots: Slot[], compareWith: Slot[]) => { + compareWith.forEach((compare) => { + const match = slots.findIndex((slot) => slot.time.isSame(compare.time)); + if (match !== -1) { + slots[match].users?.push(compare.users![0]); + } else { + slots.push(compare); + } + }); + return slots; + }; + break; + } + + if (!poolingMethod) { + throw Error(`No poolingMethod found for schedulingType: "${props.schedulingType}""`); + } + + for (let i = 1; i < results.length; i++) { + loadedSlots = poolingMethod(loadedSlots, results[i]); + } + loadedSlots = loadedSlots.sort((a, b) => (a.time.isAfter(b.time) ? 1 : -1)); setSlots(loadedSlots); setLoading(false); - return; - } - - let poolingMethod; - switch (props.schedulingType) { - // intersect by time, does not take into account eventLength (yet) - case SchedulingType.COLLECTIVE: - poolingMethod = (slots, compareWith) => - slots.filter((slot) => compareWith.some((compare) => compare.time.isSame(slot.time))); - break; - case SchedulingType.ROUND_ROBIN: - // TODO: Create a Reservation (lock this slot for X minutes) - // this will make the following code redundant - poolingMethod = (slots, compareWith) => { - compareWith.forEach((compare) => { - const match = slots.findIndex((slot) => slot.time.isSame(compare.time)); - if (match !== -1) { - slots[match].users.push(compare.users[0]); - } else { - slots.push(compare); - } - }); - return slots; - }; - break; - } - - for (let i = 1; i < results.length; i++) { - loadedSlots = poolingMethod(loadedSlots, results[i]); - } - loadedSlots = loadedSlots.sort((a, b) => (a.time.isAfter(b.time) ? 1 : -1)); - setSlots(loadedSlots); - setLoading(false); - }); + }) + .catch((e) => { + console.error(e); + setError(e); + }); }, [date]); - const handleAvailableSlots = async (res) => { + const handleAvailableSlots = async (res: Response) => { const responseBody: AvailabilityUserResponse = await res.json(); const times = getSlots({ frequency: eventLength, inviteeDate: date, workingHours: responseBody.workingHours, minimumBookingNotice, - organizerTimeZone: responseBody.timeZone, }); // Check for conflicts diff --git a/lib/slots.ts b/lib/slots.ts index 3d07d8cd1e..4f0893bade 100644 --- a/lib/slots.ts +++ b/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; diff --git a/lib/types/event-type.ts b/lib/types/event-type.ts index a74949a8b8..ad0e413463 100644 --- a/lib/types/event-type.ts +++ b/lib/types/event-type.ts @@ -1,7 +1,6 @@ -import { SchedulingType, EventType, Availability } from "@prisma/client"; +import { EventType, SchedulingType } from "@prisma/client"; -export type OpeningHours = Pick; -export type DateOverride = Pick; +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[] }; }; diff --git a/lib/types/schedule.ts b/lib/types/schedule.ts index c43c9edf05..ba5e74b45e 100644 --- a/lib/types/schedule.ts +++ b/lib/types/schedule.ts @@ -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; +}; diff --git a/pages/[user]/[type].tsx b/pages/[user]/[type].tsx index 686f94e71d..0659549b2c 100644 --- a/pages/[user]/[type].tsx +++ b/pages/[user]/[type].tsx @@ -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 { diff --git a/pages/api/availability/[user].ts b/pages/api/availability/[user].ts index 542037daba..cd8f357af3 100644 --- a/pages/api/availability/[user].ts +++ b/pages/api/availability/[user].ts @@ -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, }); } diff --git a/pages/api/availability/eventtype.ts b/pages/api/availability/eventtype.ts index e211983bd4..9e8943877a 100644 --- a/pages/api/availability/eventtype.ts +++ b/pages/api/availability/eventtype.ts @@ -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; diff --git a/pages/availability/index.tsx b/pages/availability/index.tsx index 76e3af0307..0d7817145b 100644 --- a/pages/availability/index.tsx +++ b/pages/availability/index.tsx @@ -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 (
      ) => { const [users, setUsers] = useState([]); 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(""); diff --git a/pages/team/[slug]/[type].tsx b/pages/team/[slug]/[type].tsx index 0adc152309..797be2743b 100644 --- a/pages/team/[slug]/[type].tsx +++ b/pages/team/[slug]/[type].tsx @@ -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, diff --git a/prisma/migrations/20211111013358_period_type_enum/migration.sql b/prisma/migrations/20211111013358_period_type_enum/migration.sql new file mode 100644 index 0000000000..aec7667727 --- /dev/null +++ b/prisma/migrations/20211111013358_period_type_enum/migration.sql @@ -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"; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 17aa8a1d35..5a71992c35 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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? diff --git a/scripts/seed.ts b/scripts/seed.ts index 7326c9357b..089a186b25 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -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", diff --git a/server/routers/viewer.tsx b/server/routers/viewer.tsx index d5b599d7df..941946694e 100644 --- a/server/routers/viewer.tsx +++ b/server/routers/viewer.tsx @@ -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, }; }, }) diff --git a/test/lib/getWorkingHours.test.ts b/test/lib/getWorkingHours.test.ts new file mode 100644 index 0000000000..626ff4b865 --- /dev/null +++ b/test/lib/getWorkingHours.test.ts @@ -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, + }, + ]); +}); diff --git a/test/lib/slots.test.ts b/test/lib/slots.test.ts index 9f37591ddd..371b6b733f 100644 --- a/test/lib/slots.test.ts +++ b/test/lib/slots.test.ts @@ -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); });