From 2f2b72dd5493eec80d117fcb201576e360e64209 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Wed, 14 Dec 2022 17:30:55 +0000 Subject: [PATCH] Feature/date overrides (#5991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial incomplete (but mostly functional) push of date override functions * Fixed date shifting on load * Bring back minDate (automatically disable all dates before current date) * Fix type error * Supply working hours to render available dates * Converted to SSR * moving defaultValues to the backend * Improv. as filter can be achieved within the reduce Co-authored-by: Omar López * Double inversion -> single, as it is an early return * uniq() exit - not needed anymore * Typefixes * It's overriding dates :D * Fixed duplication DateOverrides in list * Implemented changing the month * Make dateOverrides an optional param * Fixed test (which requires dateOverrides due to auto-typing) * Prevent a full update on set as default from list view * Added some extra keys to keep ts happy * Only allow a single date override per date * Disallow editing excludedDates to the same date * Bring back duplicate key ?.? Co-authored-by: Peer Richelsen Co-authored-by: zomars Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- apps/web/pages/availability/[schedule].tsx | 113 +++++++---- apps/web/public/static/locales/en/common.json | 11 + apps/web/styles/globals.css | 5 + .../test/lib/getAggregateWorkingHours.test.ts | 2 + packages/core/getUserAvailability.ts | 23 ++- packages/features/availability/README.md | 4 - .../features/bookings/lib/handleNewBooking.ts | 1 + .../components/MemberChangeRoleModal.tsx | 2 +- .../components/v2/TeamAvailabilityModal.tsx | 2 +- .../components/DateOverrideInputDialog.tsx | 189 ++++++++++++++++++ .../schedules/components/DateOverrideList.tsx | 100 +++++++++ .../schedules/components/Schedule.tsx | 18 +- .../schedules/components/ScheduleListItem.tsx | 31 ++- .../features/schedules/components/index.ts | 4 +- packages/lib/availability.ts | 2 + packages/lib/slots.ts | 188 +++++++++-------- .../server/routers/viewer/availability.tsx | 141 +++++++++++-- packages/trpc/server/routers/viewer/slots.tsx | 12 +- packages/ui/v2/core/Dialog.tsx | 14 +- packages/ui/v2/modules/booker/DatePicker.tsx | 47 ++++- 20 files changed, 707 insertions(+), 202 deletions(-) delete mode 100644 packages/features/availability/README.md create mode 100644 packages/features/schedules/components/DateOverrideInputDialog.tsx create mode 100644 packages/features/schedules/components/DateOverrideList.tsx diff --git a/apps/web/pages/availability/[schedule].tsx b/apps/web/pages/availability/[schedule].tsx index 74ad8d0dae..a4e320249f 100644 --- a/apps/web/pages/availability/[schedule].tsx +++ b/apps/web/pages/availability/[schedule].tsx @@ -1,15 +1,16 @@ -import { GetStaticPaths, GetStaticProps } from "next"; -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { GetServerSidePropsContext } from "next"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; import { z } from "zod"; +import { DateOverrideInputDialog, DateOverrideList } from "@calcom/features/schedules"; import Schedule from "@calcom/features/schedules/components/Schedule"; import { availabilityAsString } from "@calcom/lib/availability"; +import { yyyymmdd } from "@calcom/lib/date-fns"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { stringOrNumber } from "@calcom/prisma/zod-utils"; import { trpc } from "@calcom/trpc/react"; import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; -import type { Schedule as ScheduleType } from "@calcom/types/schedule"; +import type { Schedule as ScheduleType, TimeRange, WorkingHours } from "@calcom/types/schedule"; import { Button, Form, @@ -21,6 +22,7 @@ import { SkeletonText, Switch, TimezoneSelect, + Tooltip, VerticalDivider, } from "@calcom/ui"; @@ -29,7 +31,7 @@ import { HttpError } from "@lib/core/http/error"; import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader"; import EditableHeading from "@components/ui/EditableHeading"; -import { ssgInit } from "@server/lib/ssg"; +import { ssrInit } from "@server/lib/ssr"; const querySchema = z.object({ schedule: stringOrNumber, @@ -38,31 +40,59 @@ const querySchema = z.object({ type AvailabilityFormValues = { name: string; schedule: ScheduleType; + dateOverrides: { ranges: TimeRange[] }[]; timeZone: string; isDefault: boolean; }; +const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => { + const { remove, append, update, fields } = useFieldArray({ + name: "dateOverrides", + }); + const { t } = useLocale(); + return ( +
+

+ {t("date_overrides")}{" "} + + + + + +

+

{t("date_overrides_subtitle")}

+
+ yyyymmdd(field.ranges[0].start))} + remove={remove} + update={update} + items={fields} + workingHours={workingHours} + /> + yyyymmdd(field.ranges[0].start))} + onChange={(ranges) => append({ ranges })} + Trigger={ + + } + /> +
+
+ ); +}; + export default function Availability({ schedule }: { schedule: number }) { const { t, i18n } = useLocale(); const utils = trpc.useContext(); const me = useMeQuery(); const { timeFormat } = me.data || { timeFormat: null }; const { data, isLoading } = trpc.viewer.availability.schedule.get.useQuery({ scheduleId: schedule }); - - const form = useForm(); - const { control, reset } = form; - - useEffect(() => { - if (!isLoading && data) { - reset({ - name: data?.schedule?.name, - schedule: data.availability, - timeZone: data.timeZone, - isDefault: data.isDefault, - }); - } - }, [data, isLoading, reset]); - + const { data: defaultValues } = trpc.viewer.availability.defaultValues.useQuery({ scheduleId: schedule }); + const form = useForm({ defaultValues }); + const { control } = form; const updateMutation = trpc.viewer.availability.schedule.update.useMutation({ onSuccess: async ({ prevDefaultId, currentDefaultId, ...data }) => { if (prevDefaultId && currentDefaultId) { @@ -73,7 +103,7 @@ export default function Availability({ schedule }: { schedule: number }) { utils.viewer.availability.schedule.get.refetch({ scheduleId: prevDefaultId }); } } - utils.viewer.availability.schedule.get.setData({ scheduleId: data.schedule.id }, data); + utils.viewer.availability.schedule.get.invalidate({ scheduleId: data.schedule.id }); utils.viewer.availability.list.invalidate(); showToast( t("availability_updated_successfully", { @@ -103,12 +133,14 @@ export default function Availability({ schedule }: { schedule: number }) { } subtitle={ data ? ( - data.schedule.availability.map((availability) => ( - - {availabilityAsString(availability, { locale: i18n.language, hour12: timeFormat === 12 })} -
-
- )) + data.schedule.availability + .filter((availability) => !!availability.days.length) + .map((availability) => ( + + {availabilityAsString(availability, { locale: i18n.language, hour12: timeFormat === 12 })} +
+
+ )) ) : ( ) @@ -147,15 +179,16 @@ export default function Availability({ schedule }: { schedule: number }) {
{ + handleSubmit={async ({ dateOverrides, ...values }) => { updateMutation.mutate({ scheduleId: schedule, + dateOverrides: dateOverrides.flatMap((override) => override.ranges), ...values, }); }} className="-mx-4 flex flex-col pb-16 sm:mx-0 xl:flex-row xl:space-x-6"> -
-
+
+

{t("change_start_end")}

@@ -171,6 +204,7 @@ export default function Availability({ schedule }: { schedule: number }) { /> )}
+ {data?.workingHours && }
@@ -211,24 +245,19 @@ export default function Availability({ schedule }: { schedule: number }) { ); } -export const getStaticProps: GetStaticProps = async (ctx) => { +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { const params = querySchema.safeParse(ctx.params); - const ssg = await ssgInit(ctx); + const ssr = await ssrInit(ctx); if (!params.success) return { notFound: true }; + const scheduleId = params.data.schedule; + await ssr.viewer.availability.schedule.get.fetch({ scheduleId }); + await ssr.viewer.availability.defaultValues.fetch({ scheduleId }); return { props: { - schedule: params.data.schedule, - trpcState: ssg.dehydrate(), + schedule: scheduleId, + trpcState: ssr.dehydrate(), }, - revalidate: 10, // seconds - }; -}; - -export const getStaticPaths: GetStaticPaths = () => { - return { - paths: [], - fallback: "blocking", }; }; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 5d8f3de5db..c29759b7ae 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1441,6 +1441,17 @@ "enter_option": "Enter Option {{index}}", "add_an_option": "Add an option", "radio": "Radio", + "date_overrides": "Date overrides", + "date_overrides_subtitle": "Add dates when your availability changes from your daily hours.", + "date_overrides_info": "Date overrides are archived automatically after the date has passed", + "date_overrides_dialog_which_hours": "Which hours are you free?", + "date_overrides_dialog_which_hours_unavailable": "Which hours are you busy?", + "date_overrides_dialog_title": "Select the dates to override", + "date_overrides_unavailable": "Unavailable all day", + "date_overrides_mark_all_day_unavailable_one": "Mark unavailable (All day)", + "date_overrides_mark_all_day_unavailable_other": "Mark unavailable on selected dates", + "date_overrides_add_btn": "Add Override", + "date_overrides_update_btn": "Update Override", "event_type_duplicate_copy_text": "{{slug}}-copy", "set_as_default": "Set as default", "hide_eventtype_details": "Hide EventType Details" diff --git a/apps/web/styles/globals.css b/apps/web/styles/globals.css index 96f970be69..39d3615204 100644 --- a/apps/web/styles/globals.css +++ b/apps/web/styles/globals.css @@ -432,6 +432,11 @@ hr { border: none !important; } +.react-date-picker__inputGroup__input { + padding-top: 0; + padding-bottom: 0; +} + /* animations */ .slideInBottom { animation-duration: 0.3s; diff --git a/apps/web/test/lib/getAggregateWorkingHours.test.ts b/apps/web/test/lib/getAggregateWorkingHours.test.ts index f9d6cb1706..20befd4727 100644 --- a/apps/web/test/lib/getAggregateWorkingHours.test.ts +++ b/apps/web/test/lib/getAggregateWorkingHours.test.ts @@ -10,6 +10,7 @@ const HAWAII_AND_NEWYORK_TEAM = [ timeZone: "America/Detroit", // GMT -4 per 22th of Aug, 2022 workingHours: [{ days: [1, 2, 3, 4, 5], startTime: 780, endTime: 1260 }], busy: [], + dateOverrides: [], }, { timeZone: "Pacific/Honolulu", // GMT -10 per 22th of Aug, 2022 @@ -20,6 +21,7 @@ const HAWAII_AND_NEWYORK_TEAM = [ { days: [5], startTime: 780, endTime: 1439 }, ], busy: [], + dateOverrides: [], }, ]; diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 0ddddeb227..50df4224ce 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -47,6 +47,7 @@ const getEventType = async (id: number) => { startTime: true, endTime: true, days: true, + date: true, }, }, }, @@ -225,18 +226,32 @@ export async function getUserAvailability( const startGetWorkingHours = performance.now(); const timeZone = schedule.timeZone || eventType?.timeZone || currentUser.timeZone; - const workingHours = getWorkingHours( - { timeZone }, + + const availability = schedule.availability || - (eventType?.availability.length ? eventType.availability : currentUser.availability) - ); + (eventType?.availability.length ? eventType.availability : currentUser.availability); + + const workingHours = getWorkingHours({ timeZone }, availability); + const endGetWorkingHours = performance.now(); logger.debug(`getWorkingHours took ${endGetWorkingHours - startGetWorkingHours}ms for userId ${userId}`); + const dateOverrides = availability + .filter((availability) => !!availability.date) + .map((override) => { + const startTime = dayjs.utc(override.startTime); + const endTime = dayjs.utc(override.endTime); + return { + start: dayjs.utc(override.date).hour(startTime.hour()).minute(startTime.minute()).toDate(), + end: dayjs.utc(override.date).hour(endTime.hour()).minute(endTime.minute()).toDate(), + }; + }); + return { busy: bufferedBusyTimes, timeZone, workingHours, + dateOverrides, currentSeats, }; } diff --git a/packages/features/availability/README.md b/packages/features/availability/README.md deleted file mode 100644 index 85af1647b0..0000000000 --- a/packages/features/availability/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# Availability related code will live here - -- [ ] Maybe migrate `getBusyTimes` here -- [ ] Maybe migrate `getUserAvailability` here (or into `users` feature) diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 81293a0e9c..438d6a69e5 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -195,6 +195,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => { }, availability: { select: { + date: true, startTime: true, endTime: true, days: true, diff --git a/packages/features/ee/teams/components/MemberChangeRoleModal.tsx b/packages/features/ee/teams/components/MemberChangeRoleModal.tsx index 0849de0f48..1fcd11c6b3 100644 --- a/packages/features/ee/teams/components/MemberChangeRoleModal.tsx +++ b/packages/features/ee/teams/components/MemberChangeRoleModal.tsx @@ -67,7 +67,7 @@ export default function MemberChangeRoleModal(props: { } return ( - + <>
diff --git a/packages/features/ee/teams/components/v2/TeamAvailabilityModal.tsx b/packages/features/ee/teams/components/v2/TeamAvailabilityModal.tsx index d92451ad58..e794fbdd9e 100644 --- a/packages/features/ee/teams/components/v2/TeamAvailabilityModal.tsx +++ b/packages/features/ee/teams/components/v2/TeamAvailabilityModal.tsx @@ -33,7 +33,7 @@ export default function TeamAvailabilityModal(props: Props) { return ( <> -
+
{}; + +const DateOverrideForm = ({ + value, + workingHours, + excludedDates, + onChange, + onClose = noop, +}: { + workingHours?: WorkingHours[]; + onChange: (newValue: TimeRange[]) => void; + excludedDates: string[]; + value?: TimeRange[]; + onClose?: () => void; +}) => { + const [browsingDate, setBrowsingDate] = useState(); + const { t, i18n, isLocaleReady } = useLocale(); + const [datesUnavailable, setDatesUnavailable] = useState( + value && + value[0].start.getHours() === 0 && + value[0].start.getMinutes() === 0 && + value[0].end.getHours() === 0 && + value[0].end.getMinutes() === 0 + ); + + const [date, setDate] = useState(value ? dayjs(value[0].start) : null); + const includedDates = useMemo( + () => + workingHours + ? workingHours.reduce((dates, workingHour) => { + for (let dNum = 1; dNum <= daysInMonth(browsingDate || dayjs()); dNum++) { + const d = browsingDate ? browsingDate.date(dNum) : dayjs.utc().date(dNum); + if (workingHour.days.includes(d.day())) { + dates.push(yyyymmdd(d)); + } + } + return dates; + }, [] as string[]) + : [], + // eslint-disable-next-line react-hooks/exhaustive-deps + [browsingDate] + ); + + const form = useForm<{ range: TimeRange[] }>(); + const { reset } = form; + + useEffect(() => { + if (value) { + reset({ + range: value.map((range) => ({ + start: new Date( + dayjs.utc().hour(range.start.getUTCHours()).minute(range.start.getUTCMinutes()).second(0).format() + ), + end: new Date( + dayjs.utc().hour(range.end.getUTCHours()).minute(range.end.getUTCMinutes()).second(0).format() + ), + })), + }); + return; + } + const dayRanges = (workingHours || []).reduce((dayRanges, workingHour) => { + if (date && workingHour.days.includes(date.day())) { + dayRanges.push({ + start: dayjs.utc().startOf("day").add(workingHour.startTime, "minute").toDate(), + end: dayjs.utc().startOf("day").add(workingHour.endTime, "minute").toDate(), + }); + } + return dayRanges; + }, [] as TimeRange[]); + reset({ + range: dayRanges, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [date, value]); + + return ( + { + if (!date) return; + onChange( + (datesUnavailable ? [ALL_DAY_RANGE] : values.range).map((item) => ({ + start: date.hour(item.start.getHours()).minute(item.start.getMinutes()).toDate(), + end: date.hour(item.end.getHours()).minute(item.end.getMinutes()).toDate(), + })) + ); + onClose(); + }} + className="flex space-x-4"> +
+ + setDate(day)} + onMonthChange={(newMonth) => { + setBrowsingDate(newMonth); + }} + browsingDate={browsingDate} + locale={isLocaleReady ? i18n.language : "en"} + /> +
+ {date && ( +
+
+

{t("date_overrides_dialog_which_hours")}

+
+ {datesUnavailable ? ( +

+ {t("date_overrides_unavailable")} +

+ ) : ( + + )} +
+ +
+
+ + +
+
+ )} + + ); +}; + +const DateOverrideInputDialog = ({ + Trigger, + excludedDates = [], + ...passThroughProps +}: { + workingHours: WorkingHours[]; + excludedDates?: string[]; + Trigger: React.ReactNode; + onChange: (newValue: TimeRange[]) => void; + value?: TimeRange[]; +}) => { + const [open, setOpen] = useState(false); + return ( + + {Trigger} + + setOpen(false)} + /> + + + ); +}; + +export default DateOverrideInputDialog; diff --git a/packages/features/schedules/components/DateOverrideList.tsx b/packages/features/schedules/components/DateOverrideList.tsx new file mode 100644 index 0000000000..5637b291ea --- /dev/null +++ b/packages/features/schedules/components/DateOverrideList.tsx @@ -0,0 +1,100 @@ +import { UseFieldArrayRemove } from "react-hook-form"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { TimeRange, WorkingHours } from "@calcom/types/schedule"; +import { Button, DialogTrigger, Icon, Tooltip } from "@calcom/ui"; + +import DateOverrideInputDialog from "./DateOverrideInputDialog"; + +const DateOverrideList = ({ + items, + remove, + update, + workingHours, + excludedDates = [], +}: { + remove: UseFieldArrayRemove; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + update: any; + items: { ranges: TimeRange[]; id: string }[]; + workingHours: WorkingHours[]; + excludedDates?: string[]; +}) => { + const { t, i18n } = useLocale(); + if (!items.length) { + return <>; + } + + const timeSpan = ({ start, end }: TimeRange) => { + return ( + new Intl.DateTimeFormat(i18n.language, { hour: "numeric", minute: "numeric", hour12: true }).format( + new Date(start.toISOString().slice(0, -1)) + ) + + " - " + + new Intl.DateTimeFormat(i18n.language, { hour: "numeric", minute: "numeric", hour12: true }).format( + new Date(end.toISOString().slice(0, -1)) + ) + ); + }; + + return ( +
    + {items.map((item, index) => ( +
  • +
    +

    + {new Intl.DateTimeFormat("en-GB", { + weekday: "short", + month: "long", + day: "numeric", + }).format(item.ranges[0].start)} +

    + {item.ranges[0].end.getUTCHours() === 0 && item.ranges[0].end.getUTCMinutes() === 0 ? ( +

    {t("unavailable")}

    + ) : ( + item.ranges.map((range, i) => ( +

    + {timeSpan(range)} +

    + )) + )} +
    +
    + { + update(index, { + ranges, + }); + }} + Trigger={ + +
    +
  • + ))} +
+ ); +}; + +export default DateOverrideList; diff --git a/packages/features/schedules/components/Schedule.tsx b/packages/features/schedules/components/Schedule.tsx index 15ea28d24d..d54ccc6462 100644 --- a/packages/features/schedules/components/Schedule.tsx +++ b/packages/features/schedules/components/Schedule.tsx @@ -30,6 +30,8 @@ import { Switch, } from "@calcom/ui"; +export type { TimeRange }; + export type FieldPathByValue = { [Key in FieldPath]: FieldPathValue extends TValue ? Key : never; }[FieldPath]; @@ -40,7 +42,7 @@ const ScheduleDay = ({ control, CopyButton, }: { - name: string; + name: ArrayPath; weekday: string; control: Control; CopyButton: JSX.Element; @@ -60,7 +62,7 @@ const ScheduleDay = ({ defaultChecked={watchDayRange && watchDayRange.length > 0} checked={watchDayRange && !!watchDayRange.length} onCheckedChange={(isChecked) => { - setValue(name, isChecked ? [DEFAULT_DAY_RANGE] : []); + setValue(name, (isChecked ? [DEFAULT_DAY_RANGE] : []) as TFieldValues[typeof name]); }} />
@@ -142,7 +144,7 @@ const Schedule = < {/* First iterate for each day */} {weekdayNames(i18n.language, weekStart, "long").map((weekday, num) => { const weekdayIndex = (num + weekStart) % 7; - const dayRangeName = `${name}.${weekdayIndex}`; + const dayRangeName = `${name}.${weekdayIndex}` as ArrayPath; return ( ({ +export const DayRanges = ({ name, control, }: { - name: string; - control: Control; + name: ArrayPath; + control?: Control; }) => { const { t } = useLocale(); const { remove, fields, append } = useFieldArray({ control, - name: name as unknown as ArrayPath, + name, }); return ( @@ -224,7 +226,7 @@ const RemoveTimeButton = ({ const TimeRangeField = ({ className, value, onChange }: { className?: string } & ControllerRenderProps) => { // this is a controlled component anyway given it uses LazySelect, so keep it RHF agnostic. return ( -
+
void; + updateDefault: ({ scheduleId, isDefault }: { scheduleId: number; isDefault: boolean }) => void; }) { const { t, i18n } = useLocale(); @@ -59,15 +51,17 @@ export function ScheduleListItem({ )}

- {schedule.availability.map((availability: Availability) => ( - - {availabilityAsString(availability, { - locale: i18n.language, - hour12: displayOptions?.hour12, - })} -
-
- ))} + {schedule.availability + .filter((availability) => !!availability.days.length) + .map((availability) => ( + + {availabilityAsString(availability, { + locale: i18n.language, + hour12: displayOptions?.hour12, + })} +
+
+ ))} {schedule.timeZone && schedule.timeZone !== displayOptions?.timeZone && (

@@ -95,7 +89,6 @@ export function ScheduleListItem({ updateDefault({ scheduleId: schedule.id, isDefault: true, - schedule: data.availability, }); }}> {t("set_as_default")} diff --git a/packages/features/schedules/components/index.ts b/packages/features/schedules/components/index.ts index a6df3bef22..7d8653ecad 100644 --- a/packages/features/schedules/components/index.ts +++ b/packages/features/schedules/components/index.ts @@ -1,3 +1,5 @@ export { NewScheduleButton } from "./NewScheduleButton"; -export { default as Schedule } from "./Schedule"; export { ScheduleListItem } from "./ScheduleListItem"; +export { default as DateOverrideInputDialog } from "./DateOverrideInputDialog"; +export { default as Schedule } from "./Schedule"; +export { default as DateOverrideList } from "./DateOverrideList"; diff --git a/packages/lib/availability.ts b/packages/lib/availability.ts index 2244b98860..7beab8349a 100644 --- a/packages/lib/availability.ts +++ b/packages/lib/availability.ts @@ -74,6 +74,8 @@ export function getWorkingHours( (relativeTimeUnit.timeZone ? dayjs().tz(relativeTimeUnit.timeZone).utcOffset() : 0); const workingHours = availability.reduce((currentWorkingHours: WorkingHours[], schedule) => { + // Include only recurring weekly availability, not date overrides + if (!schedule.days.length) return currentWorkingHours; // Get times localised to the given utcOffset/timeZone const startTime = dayjs.utc(schedule.startTime).get("hour") * 60 + diff --git a/packages/lib/slots.ts b/packages/lib/slots.ts index ef84d0544c..11f143fba2 100644 --- a/packages/lib/slots.ts +++ b/packages/lib/slots.ts @@ -1,5 +1,5 @@ import dayjs, { Dayjs } from "@calcom/dayjs"; -import { WorkingHours } from "@calcom/types/schedule"; +import { WorkingHours, TimeRange as DateOverride } from "@calcom/types/schedule"; import { getWorkingHours } from "./availability"; @@ -7,10 +7,11 @@ export type GetSlots = { inviteeDate: Dayjs; frequency: number; workingHours: WorkingHours[]; + dateOverrides?: DateOverride[]; minimumBookingNotice: number; eventLength: number; }; -export type WorkingHoursTimeFrame = { startTime: number; endTime: number }; +export type TimeFrame = { startTime: number; endTime: number }; /** * TODO: What does this function do? @@ -21,10 +22,10 @@ const splitAvailableTime = ( endTimeMinutes: number, frequency: number, eventLength: number -): Array => { +): TimeFrame[] => { let initialTime = startTimeMinutes; const finalizationTime = endTimeMinutes; - const result = [] as Array; + const result = [] as TimeFrame[]; while (initialTime < finalizationTime) { const periodTime = initialTime + frequency; const slotEndTime = initialTime + eventLength; @@ -39,77 +40,27 @@ const splitAvailableTime = ( return result; }; -const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours, eventLength }: GetSlots) => { - // current date in invitee tz - const startDate = dayjs().add(minimumBookingNotice, "minute"); - // This code is ran client side, startOf() does some conversions based on the - // local tz of the client. Sometimes this shifts the day incorrectly. - const startOfDayUTC = dayjs.utc().set("hour", 0).set("minute", 0).set("second", 0); - const startOfInviteeDay = inviteeDate.startOf("day"); - // checks if the start date is in the past +function buildSlots({ + startOfInviteeDay, + computedLocalAvailability, + frequency, + eventLength, + startDate, +}: { + computedLocalAvailability: TimeFrame[]; + startOfInviteeDay: Dayjs; + startDate: Dayjs; + frequency: number; + eventLength: number; +}) { + const slotsTimeFrameAvailable: TimeFrame[] = []; - /** - * TODO: change "day" for "hour" to stop displaying 1 day before today - * This is displaying a day as available as sometimes difference between two dates is < 24 hrs. - * But when doing timezones an available day for an owner can be 2 days available in other users tz. - * - * */ - if (inviteeDate.isBefore(startDate, "day")) { - return []; - } - - const workingHoursUTC = workingHours.map((schedule) => ({ - days: schedule.days, - startTime: /* Why? */ startOfDayUTC.add(schedule.startTime, "minute"), - endTime: /* Why? */ startOfDayUTC.add(schedule.endTime, "minute"), - })); - - // Dayjs does not expose the timeZone value publicly through .get("timeZone") - // instead, we as devs are required to somewhat hack our way to get the ... - // tz value as string - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const timeZone: string = (inviteeDate as any)["$x"]["$timezone"]; - - const localWorkingHours = getWorkingHours( - { - // initialize current day with timeZone without conversion, just parse. - utcOffset: -dayjs.tz(dayjs(), timeZone).utcOffset(), - }, - workingHoursUTC - ).filter((hours) => hours.days.includes(inviteeDate.day())); - - const slots: Dayjs[] = []; - - const slotsTimeFrameAvailable = [] as Array; - // Here we split working hour in chunks for every frequency available that can fit in whole working hours - const computedLocalWorkingHours: WorkingHoursTimeFrame[] = []; - let tempComputeTimeFrame: WorkingHoursTimeFrame | undefined; - const computeLength = localWorkingHours.length - 1; - const makeTimeFrame = (item: typeof localWorkingHours[0]): WorkingHoursTimeFrame => ({ - startTime: item.startTime, - endTime: item.endTime, - }); - localWorkingHours.forEach((item, index) => { - if (!tempComputeTimeFrame) { - tempComputeTimeFrame = makeTimeFrame(item); - } else { - // please check the comment in splitAvailableTime func for the added 1 minute - if (tempComputeTimeFrame.endTime + 1 === item.startTime) { - // to deal with time that across the day, e.g. from 11:59 to to 12:01 - tempComputeTimeFrame.endTime = item.endTime; - } else { - computedLocalWorkingHours.push(tempComputeTimeFrame); - tempComputeTimeFrame = makeTimeFrame(item); - } - } - if (index == computeLength) { - computedLocalWorkingHours.push(tempComputeTimeFrame); - } - }); - computedLocalWorkingHours.forEach((item) => { + computedLocalAvailability.forEach((item) => { slotsTimeFrameAvailable.push(...splitAvailableTime(item.startTime, item.endTime, frequency, eventLength)); }); + const slots: Dayjs[] = []; + slotsTimeFrameAvailable.forEach((item) => { // XXX: Hack alert, as dayjs is supposedly not aware of timezone the current slot may have invalid UTC offset. const timeZone = (startOfInviteeDay as unknown as { $x: { $timezone: string } })["$x"]["$timezone"]; @@ -135,14 +86,95 @@ const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours, } }); - const uniq = (a: Dayjs[]) => { - const seen: Record = {}; - return a.filter((item) => { - return seen.hasOwnProperty(item.format()) ? false : (seen[item.format()] = true); - }); - }; + return slots; +} - return uniq(slots); +const getSlots = ({ + inviteeDate, + frequency, + minimumBookingNotice, + workingHours, + dateOverrides = [], + eventLength, +}: GetSlots) => { + // current date in invitee tz + const startDate = dayjs().add(minimumBookingNotice, "minute"); + // This code is ran client side, startOf() does some conversions based on the + // local tz of the client. Sometimes this shifts the day incorrectly. + const startOfDayUTC = dayjs.utc().set("hour", 0).set("minute", 0).set("second", 0); + const startOfInviteeDay = inviteeDate.startOf("day"); + // checks if the start date is in the past + + /** + * TODO: change "day" for "hour" to stop displaying 1 day before today + * This is displaying a day as available as sometimes difference between two dates is < 24 hrs. + * But when doing timezones an available day for an owner can be 2 days available in other users tz. + * + * */ + if (inviteeDate.isBefore(startDate, "day")) { + return []; + } + + // Dayjs does not expose the timeZone value publicly through .get("timeZone") + // instead, we as devs are required to somewhat hack our way to get the ... + // tz value as string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const timeZone: string = (inviteeDate as any)["$x"]["$timezone"]; + + // an override precedes all the local working hour availability logic. + const activeOverrides = dateOverrides.filter((override) => + dayjs.utc(override.start).tz(timeZone).isSame(startOfInviteeDay, "day") + ); + if (!!activeOverrides.length) { + const computedLocalAvailability = activeOverrides.flatMap((override) => ({ + startTime: override.start.getUTCHours() * 60 + override.start.getUTCMinutes(), + endTime: override.end.getUTCHours() * 60 + override.end.getUTCMinutes(), + })); + return buildSlots({ computedLocalAvailability, startDate, startOfInviteeDay, eventLength, frequency }); + } + + const workingHoursUTC = workingHours.map((schedule) => ({ + days: schedule.days, + startTime: /* Why? */ startOfDayUTC.add(schedule.startTime, "minute"), + endTime: /* Why? */ startOfDayUTC.add(schedule.endTime, "minute"), + })); + + const localWorkingHours = getWorkingHours( + { + // initialize current day with timeZone without conversion, just parse. + utcOffset: -dayjs.tz(dayjs(), timeZone).utcOffset(), + }, + workingHoursUTC + ).filter((hours) => hours.days.includes(inviteeDate.day())); + + // Here we split working hour in chunks for every frequency available that can fit in whole working hours + const computedLocalAvailability: TimeFrame[] = []; + let tempComputeTimeFrame: TimeFrame | undefined; + const computeLength = localWorkingHours.length - 1; + const makeTimeFrame = (item: typeof localWorkingHours[0]): TimeFrame => ({ + startTime: item.startTime, + endTime: item.endTime, + }); + + localWorkingHours.forEach((item, index) => { + if (!tempComputeTimeFrame) { + tempComputeTimeFrame = makeTimeFrame(item); + } else { + // please check the comment in splitAvailableTime func for the added 1 minute + if (tempComputeTimeFrame.endTime + 1 === item.startTime) { + // to deal with time that across the day, e.g. from 11:59 to to 12:01 + tempComputeTimeFrame.endTime = item.endTime; + } else { + computedLocalAvailability.push(tempComputeTimeFrame); + tempComputeTimeFrame = makeTimeFrame(item); + } + } + if (index == computeLength) { + computedLocalAvailability.push(tempComputeTimeFrame); + } + }); + + return buildSlots({ computedLocalAvailability, startOfInviteeDay, startDate, frequency, eventLength }); }; export default getSlots; diff --git a/packages/trpc/server/routers/viewer/availability.tsx b/packages/trpc/server/routers/viewer/availability.tsx index 7e7d32c1a6..11a2774025 100644 --- a/packages/trpc/server/routers/viewer/availability.tsx +++ b/packages/trpc/server/routers/viewer/availability.tsx @@ -2,14 +2,16 @@ import { Availability as AvailabilityModel, Prisma, Schedule as ScheduleModel, U import { z } from "zod"; import { getUserAvailability } from "@calcom/core/getUserAvailability"; -import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability"; +import dayjs from "@calcom/dayjs"; +import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule, getWorkingHours } from "@calcom/lib/availability"; +import { yyyymmdd } from "@calcom/lib/date-fns"; import { PrismaClient } from "@calcom/prisma/client"; import { stringOrNumber } from "@calcom/prisma/zod-utils"; -import { Schedule } from "@calcom/types/schedule"; +import { Schedule, TimeRange } from "@calcom/types/schedule"; import { TRPCError } from "@trpc/server"; -import { router, authedProcedure } from "../../trpc"; +import { authedProcedure, router } from "../../trpc"; export const availabilityRouter = router({ list: authedProcedure.query(async ({ ctx }) => { @@ -52,6 +54,70 @@ export const availabilityRouter = router({ .query(({ input }) => { return getUserAvailability(input); }), + defaultValues: authedProcedure.input(z.object({ scheduleId: z.number() })).query(async ({ ctx, input }) => { + const { prisma, user } = ctx; + const schedule = await prisma.schedule.findUnique({ + where: { + id: input.scheduleId || (await getDefaultScheduleId(user.id, prisma)), + }, + select: { + id: true, + userId: true, + name: true, + availability: true, + timeZone: true, + eventType: { + select: { + _count: true, + id: true, + eventName: true, + }, + }, + }, + }); + if (!schedule || schedule.userId !== user.id) { + throw new TRPCError({ + code: "UNAUTHORIZED", + }); + } + const availability = convertScheduleToAvailability(schedule); + return { + name: schedule.name, + rawSchedule: schedule, + schedule: availability, + dateOverrides: schedule.availability.reduce((acc, override) => { + // only iff future date override + if (!override.date || override.date < new Date()) { + return acc; + } + const newValue = { + start: dayjs + .utc(override.date) + .hour(override.startTime.getUTCHours()) + .minute(override.startTime.getUTCMinutes()) + .toDate(), + end: dayjs + .utc(override.date) + .hour(override.endTime.getUTCHours()) + .minute(override.endTime.getUTCMinutes()) + .toDate(), + }; + const dayRangeIndex = acc.findIndex( + // early return prevents override.date from ever being empty. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (item) => yyyymmdd(item.ranges[0].start) === yyyymmdd(override.date!) + ); + if (dayRangeIndex === -1) { + acc.push({ ranges: [newValue] }); + return acc; + } + acc[dayRangeIndex].ranges.push(newValue); + return acc; + }, [] as { ranges: TimeRange[] }[]), + timeZone: schedule.timeZone || user.timeZone, + isDefault: !input.scheduleId || user.defaultScheduleId === schedule.id, + }; + }), schedule: router({ get: authedProcedure .input( @@ -88,6 +154,10 @@ export const availabilityRouter = router({ const availability = convertScheduleToAvailability(schedule); return { schedule, + workingHours: getWorkingHours( + { timeZone: schedule.timeZone || undefined }, + schedule.availability || [] + ), availability, timeZone: schedule.timeZone || user.timeZone, isDefault: !input.scheduleId || user.defaultScheduleId === schedule.id, @@ -201,25 +271,36 @@ export const availabilityRouter = router({ timeZone: z.string().optional(), name: z.string().optional(), isDefault: z.boolean().optional(), - schedule: z.array( - z.array( + schedule: z + .array( + z.array( + z.object({ + start: z.date(), + end: z.date(), + }) + ) + ) + .optional(), + dateOverrides: z + .array( z.object({ start: z.date(), end: z.date(), }) ) - ), + .optional(), }) ) .mutation(async ({ input, ctx }) => { const { user, prisma } = ctx; - const availability = getAvailabilityFromSchedule(input.schedule); - - let updatedUser; - if (input.isDefault) { - const setupDefault = await setupDefaultSchedule(user.id, input.scheduleId, prisma); - updatedUser = setupDefault; - } + const availability = input.schedule + ? getAvailabilityFromSchedule(input.schedule) + : (input.dateOverrides || []).map((dateOverride) => ({ + startTime: dateOverride.start, + endTime: dateOverride.end, + date: dateOverride.start, + days: [], + })); // Not able to update the schedule with userId where clause, so fetch schedule separately and then validate // Bug: https://github.com/prisma/prisma/issues/7290 @@ -229,6 +310,8 @@ export const availabilityRouter = router({ }, select: { userId: true, + name: true, + id: true, }, }); @@ -240,6 +323,25 @@ export const availabilityRouter = router({ }); } + let updatedUser; + if (input.isDefault) { + const setupDefault = await setupDefaultSchedule(user.id, input.scheduleId, prisma); + updatedUser = setupDefault; + } + + if (!input.name) { + // TODO: Improve + // We don't want to pass the full schedule for just a set as default update + // but in the current logic, this wipes the existing availability. + // Return early to prevent this from happening. + return { + schedule: userSchedule, + isDefault: updatedUser + ? updatedUser.defaultScheduleId === input.scheduleId + : user.defaultScheduleId === input.scheduleId, + }; + } + const schedule = await prisma.schedule.update({ where: { id: input.scheduleId, @@ -254,11 +356,14 @@ export const availabilityRouter = router({ }, }, createMany: { - data: availability.map((schedule) => ({ - days: schedule.days, - startTime: schedule.startTime, - endTime: schedule.endTime, - })), + data: [ + ...availability, + ...(input.dateOverrides || []).map((override) => ({ + date: override.start, + startTime: override.start, + endTime: override.end, + })), + ], }, }, }, diff --git a/packages/trpc/server/routers/viewer/slots.tsx b/packages/trpc/server/routers/viewer/slots.tsx index 86ddba16ba..23d553c271 100644 --- a/packages/trpc/server/routers/viewer/slots.tsx +++ b/packages/trpc/server/routers/viewer/slots.tsx @@ -138,6 +138,7 @@ async function getEventType(ctx: { prisma: typeof prisma }, input: z.infer, ctx: let currentSeats: CurrentSeats | undefined = undefined; /* We get all users working hours and busy slots */ - const usersWorkingHoursAndBusySlots = await Promise.all( + const userAvailability = await Promise.all( eventType.users.map(async (currentUser) => { const { busy, workingHours, + dateOverrides, currentSeats: _currentSeats, timeZone, } = await getUserAvailability( @@ -251,11 +253,14 @@ export async function getSchedule(input: z.infer, ctx: return { timeZone, workingHours, + dateOverrides, busy, }; }) ); - const workingHours = getAggregateWorkingHours(usersWorkingHoursAndBusySlots, eventType.schedulingType); + // flattens availability of multiple users + const dateOverrides = userAvailability.flatMap((availability) => availability.dateOverrides); + const workingHours = getAggregateWorkingHours(userAvailability, eventType.schedulingType); const computedAvailableSlots: Record = {}; const availabilityCheckProps = { eventLength: eventType.length, @@ -284,6 +289,7 @@ export async function getSchedule(input: z.infer, ctx: inviteeDate: currentCheckedTime, eventLength: input.duration || eventType.length, workingHours, + dateOverrides, minimumBookingNotice: eventType.minimumBookingNotice, frequency: eventType.slotInterval || input.duration || eventType.length, }); @@ -298,7 +304,7 @@ export async function getSchedule(input: z.infer, ctx: : ("some" as const); const availableTimeSlots = timeSlots.filter(isTimeWithinBounds).filter((time) => - usersWorkingHoursAndBusySlots[filterStrategy]((schedule) => { + userAvailability[filterStrategy]((schedule) => { const startCheckForAvailability = performance.now(); const isAvailable = checkIfIsAvailable({ time, ...schedule, ...availabilityCheckProps }); const endCheckForAvailability = performance.now(); diff --git a/packages/ui/v2/core/Dialog.tsx b/packages/ui/v2/core/Dialog.tsx index 8b296659b1..1719fa2617 100644 --- a/packages/ui/v2/core/Dialog.tsx +++ b/packages/ui/v2/core/Dialog.tsx @@ -32,18 +32,6 @@ export function Dialog(props: DialogProps) { delete router.query[queryParam]; }); } - router.push( - { - // This is temporary till we are doing rewrites to /v2. - // If not done, opening/closing a modalbox can take the user to /v2 paths. - pathname: router.pathname.replace("/v2", ""), - query: { - ...router.query, - }, - }, - undefined, - { shallow: true } - ); setOpen(open); }; // handles initial state @@ -88,7 +76,7 @@ export const DialogContent = React.forwardRef void; /** which date is currently selected (not tracked from here) */ - selected?: Dayjs; + selected?: Dayjs | null; /** defaults to current date. */ minDate?: Dayjs; /** Furthest date selectable in the future, default = UNLIMITED */ @@ -37,38 +37,41 @@ export type DatePickerProps = { export const Day = ({ date, active, + disabled, ...props -}: JSX.IntrinsicElements["button"] & { active: boolean; date: Dayjs }) => { +}: JSX.IntrinsicElements["button"] & { + active: boolean; + date: Dayjs; +}) => { const enabledDateButtonEmbedStyles = useEmbedStyles("enabledDateButton"); const disabledDateButtonEmbedStyles = useEmbedStyles("disabledDateButton"); return ( ); }; const Days = ({ - // minDate, + minDate = dayjs.utc(), excludedDates = [], - includedDates, browsingDate, weekStart, DayComponent = Day, @@ -81,6 +84,28 @@ const Days = ({ }) => { // Create placeholder elements for empty days in first week const weekdayOfFirst = browsingDate.day(); + const currentDate = minDate.utcOffset(browsingDate.utcOffset()); + + const availableDates = (includedDates: string[] | undefined) => { + const dates = []; + const lastDateOfMonth = browsingDate.date(daysInMonth(browsingDate)); + for ( + let date = currentDate; + date.isBefore(lastDateOfMonth) || date.isSame(lastDateOfMonth, "day"); + date = date.add(1, "day") + ) { + // even if availableDates is given, filter out the passed included dates + if (includedDates && !includedDates.includes(yyyymmdd(date))) { + continue; + } + dates.push(yyyymmdd(date)); + } + return dates; + }; + + const includedDates = currentDate.isSame(browsingDate, "month") + ? availableDates(props.includedDates) + : props.includedDates; const days: (Dayjs | null)[] = Array((weekdayOfFirst - weekStart + 7) % 7).fill(null); for (let day = 1, dayCount = daysInMonth(browsingDate); day <= dayCount; day++) { @@ -162,6 +187,7 @@ const DatePicker = ({