Feature/date overrides (#5991)

* 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 <zomars@me.com>

* 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 <peeroke@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Alex van Andel 2022-12-14 17:30:55 +00:00 committed by GitHub
parent 8fd5d6b5b5
commit 2f2b72dd54
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 707 additions and 202 deletions

View File

@ -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<AvailabilityFormValues, "dateOverrides">({
name: "dateOverrides",
});
const { t } = useLocale();
return (
<div className="px-4 py-5 sm:p-6">
<h3 className="font-medium leading-6 text-gray-900">
{t("date_overrides")}{" "}
<Tooltip content={t("date_overrides_info")}>
<span className="inline-block">
<Icon.FiInfo />
</span>
</Tooltip>
</h3>
<p className="mb-4 text-sm text-neutral-500 ltr:mr-4 rtl:ml-4">{t("date_overrides_subtitle")}</p>
<div className="mt-1 space-y-2">
<DateOverrideList
excludedDates={fields.map((field) => yyyymmdd(field.ranges[0].start))}
remove={remove}
update={update}
items={fields}
workingHours={workingHours}
/>
<DateOverrideInputDialog
workingHours={workingHours}
excludedDates={fields.map((field) => yyyymmdd(field.ranges[0].start))}
onChange={(ranges) => append({ ranges })}
Trigger={
<Button color="secondary" StartIcon={Icon.FiPlus}>
Add an override
</Button>
}
/>
</div>
</div>
);
};
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<AvailabilityFormValues>();
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<AvailabilityFormValues>({ 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) => (
<span key={availability.id}>
{availabilityAsString(availability, { locale: i18n.language, hour12: timeFormat === 12 })}
<br />
</span>
))
data.schedule.availability
.filter((availability) => !!availability.days.length)
.map((availability) => (
<span key={availability.id}>
{availabilityAsString(availability, { locale: i18n.language, hour12: timeFormat === 12 })}
<br />
</span>
))
) : (
<SkeletonText className="h-4 w-48" />
)
@ -147,15 +179,16 @@ export default function Availability({ schedule }: { schedule: number }) {
<Form
form={form}
id="availability-form"
handleSubmit={async (values) => {
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">
<div className="flex-1">
<div className="rounded-md border-gray-200 bg-white py-5 pr-4 sm:border sm:p-6">
<div className="flex-1 divide-y divide-neutral-200 rounded-md border">
<div className=" py-5 pr-4 sm:p-6">
<h3 className="mb-5 text-base font-medium leading-6 text-gray-900">
{t("change_start_end")}
</h3>
@ -171,6 +204,7 @@ export default function Availability({ schedule }: { schedule: number }) {
/>
)}
</div>
{data?.workingHours && <DateOverride workingHours={data.workingHours} />}
</div>
<div className="min-w-40 col-span-3 space-y-2 lg:col-span-1">
<div className="xl:max-w-80 mt-4 w-full pr-4 sm:p-0">
@ -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",
};
};

View File

@ -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"

View File

@ -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;

View File

@ -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: [],
},
];

View File

@ -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,
};
}

View File

@ -1,4 +0,0 @@
# Availability related code will live here
- [ ] Maybe migrate `getBusyTimes` here
- [ ] Maybe migrate `getUserAvailability` here (or into `users` feature)

View File

@ -195,6 +195,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
},
availability: {
select: {
date: true,
startTime: true,
endTime: true,
days: true,

View File

@ -67,7 +67,7 @@ export default function MemberChangeRoleModal(props: {
}
return (
<Dialog open={props.isOpen} onOpenChange={props.onExit}>
<DialogContent type="creation" size="md">
<DialogContent type="creation">
<>
<div className="mb-4 sm:flex sm:items-start">
<div className="text-center sm:text-left">

View File

@ -33,7 +33,7 @@ export default function TeamAvailabilityModal(props: Props) {
return (
<LicenseRequired>
<>
<div className="grid h-[400px] w-[36.7rem] grid-cols-2 space-x-11 rtl:space-x-reverse">
<div className="grid h-[400px] grid-cols-2 space-x-11 rtl:space-x-reverse">
<div className="col-span-1">
<div className="flex">
<Avatar

View File

@ -0,0 +1,189 @@
import { useState, useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import dayjs, { Dayjs } from "@calcom/dayjs";
import { daysInMonth, yyyymmdd } from "@calcom/lib/date-fns";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WorkingHours } from "@calcom/types/schedule";
import {
Dialog,
DialogContent,
DialogTrigger,
DialogHeader,
DialogClose,
Switch,
DatePicker,
Form,
Button,
} from "@calcom/ui";
import { DayRanges, TimeRange } from "./Schedule";
const ALL_DAY_RANGE = {
start: new Date(dayjs.utc().hour(0).minute(0).second(0).format()),
end: new Date(dayjs.utc().hour(0).minute(0).second(0).format()),
};
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {};
const DateOverrideForm = ({
value,
workingHours,
excludedDates,
onChange,
onClose = noop,
}: {
workingHours?: WorkingHours[];
onChange: (newValue: TimeRange[]) => void;
excludedDates: string[];
value?: TimeRange[];
onClose?: () => void;
}) => {
const [browsingDate, setBrowsingDate] = useState<Dayjs>();
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<Dayjs | null>(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 (
<Form
form={form}
handleSubmit={(values) => {
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">
<div className="w-1/2 border-r pr-6">
<DialogHeader title={t("date_overrides_dialog_title")} />
<DatePicker
includedDates={includedDates}
excludedDates={excludedDates}
weekStart={0}
selected={date}
onChange={(day) => setDate(day)}
onMonthChange={(newMonth) => {
setBrowsingDate(newMonth);
}}
browsingDate={browsingDate}
locale={isLocaleReady ? i18n.language : "en"}
/>
</div>
{date && (
<div className="relative flex w-1/2 flex-col pl-2">
<div className="mb-4 flex-grow space-y-4">
<p className="text-medium text-sm">{t("date_overrides_dialog_which_hours")}</p>
<div>
{datesUnavailable ? (
<p className="rounded border p-2 text-sm text-neutral-500">
{t("date_overrides_unavailable")}
</p>
) : (
<DayRanges name="range" />
)}
</div>
<Switch
label={t("date_overrides_mark_all_day_unavailable_one")}
checked={datesUnavailable}
onCheckedChange={setDatesUnavailable}
/>
</div>
<div className="flex flex-row-reverse">
<Button className="ml-2" color="primary" type="submit" disabled={!date}>
{value ? t("date_overrides_update_btn") : t("date_overrides_add_btn")}
</Button>
<DialogClose onClick={onClose} />
</div>
</div>
)}
</Form>
);
};
const DateOverrideInputDialog = ({
Trigger,
excludedDates = [],
...passThroughProps
}: {
workingHours: WorkingHours[];
excludedDates?: string[];
Trigger: React.ReactNode;
onChange: (newValue: TimeRange[]) => void;
value?: TimeRange[];
}) => {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{Trigger}</DialogTrigger>
<DialogContent size="md">
<DateOverrideForm
excludedDates={excludedDates}
{...passThroughProps}
onClose={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
);
};
export default DateOverrideInputDialog;

View File

@ -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 (
<ul className="rounded border border-gray-200">
{items.map((item, index) => (
<li key={item.id} className="flex justify-between border-b px-5 py-4 last:border-b-0">
<div>
<h3 className="text-sm text-gray-900">
{new Intl.DateTimeFormat("en-GB", {
weekday: "short",
month: "long",
day: "numeric",
}).format(item.ranges[0].start)}
</h3>
{item.ranges[0].end.getUTCHours() === 0 && item.ranges[0].end.getUTCMinutes() === 0 ? (
<p className="text-xs text-neutral-500">{t("unavailable")}</p>
) : (
item.ranges.map((range, i) => (
<p key={i} className="text-xs text-neutral-500">
{timeSpan(range)}
</p>
))
)}
</div>
<div className="space-x-2">
<DateOverrideInputDialog
excludedDates={excludedDates}
workingHours={workingHours}
value={item.ranges}
onChange={(ranges) => {
update(index, {
ranges,
});
}}
Trigger={
<DialogTrigger asChild>
<Button
tooltip={t("edit")}
className="text-gray-700"
color="minimal"
size="icon"
StartIcon={Icon.FiEdit2}
/>
</DialogTrigger>
}
/>
<Tooltip content="Delete">
<Button
className="text-gray-700"
color="destructive"
size="icon"
StartIcon={Icon.FiTrash2}
onClick={() => remove(index)}
/>
</Tooltip>
</div>
</li>
))}
</ul>
);
};
export default DateOverrideList;

View File

@ -30,6 +30,8 @@ import {
Switch,
} from "@calcom/ui";
export type { TimeRange };
export type FieldPathByValue<TFieldValues extends FieldValues, TValue> = {
[Key in FieldPath<TFieldValues>]: FieldPathValue<TFieldValues, Key> extends TValue ? Key : never;
}[FieldPath<TFieldValues>];
@ -40,7 +42,7 @@ const ScheduleDay = <TFieldValues extends FieldValues>({
control,
CopyButton,
}: {
name: string;
name: ArrayPath<TFieldValues>;
weekday: string;
control: Control<TFieldValues>;
CopyButton: JSX.Element;
@ -60,7 +62,7 @@ const ScheduleDay = <TFieldValues extends FieldValues>({
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]);
}}
/>
</div>
@ -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<TFieldValues>;
return (
<ScheduleDay
name={dayRangeName}
@ -157,18 +159,18 @@ const Schedule = <
);
};
const DayRanges = <TFieldValues extends FieldValues>({
export const DayRanges = <TFieldValues extends FieldValues>({
name,
control,
}: {
name: string;
control: Control<TFieldValues>;
name: ArrayPath<TFieldValues>;
control?: Control<TFieldValues>;
}) => {
const { t } = useLocale();
const { remove, fields, append } = useFieldArray({
control,
name: name as unknown as ArrayPath<TFieldValues>,
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 (
<div className={classNames("mr-1 sm:mx-1", className)}>
<div className={className}>
<LazySelect
className="inline-block h-9 w-[100px]"
value={value.start}

View File

@ -30,15 +30,7 @@ export function ScheduleListItem({
timeZone?: string;
hour12?: boolean;
};
updateDefault: ({
scheduleId,
isDefault,
schedule,
}: {
scheduleId: number;
isDefault: boolean;
schedule: Schedule;
}) => void;
updateDefault: ({ scheduleId, isDefault }: { scheduleId: number; isDefault: boolean }) => void;
}) {
const { t, i18n } = useLocale();
@ -59,15 +51,17 @@ export function ScheduleListItem({
)}
</div>
<p className="mt-1 text-xs text-neutral-500">
{schedule.availability.map((availability: Availability) => (
<Fragment key={availability.id}>
{availabilityAsString(availability, {
locale: i18n.language,
hour12: displayOptions?.hour12,
})}
<br />
</Fragment>
))}
{schedule.availability
.filter((availability) => !!availability.days.length)
.map((availability) => (
<Fragment key={availability.id}>
{availabilityAsString(availability, {
locale: i18n.language,
hour12: displayOptions?.hour12,
})}
<br />
</Fragment>
))}
{schedule.timeZone && schedule.timeZone !== displayOptions?.timeZone && (
<p className="my-1 flex items-center first-letter:text-xs">
<Icon.FiGlobe />
@ -95,7 +89,6 @@ export function ScheduleListItem({
updateDefault({
scheduleId: schedule.id,
isDefault: true,
schedule: data.availability,
});
}}>
{t("set_as_default")}

View File

@ -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";

View File

@ -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 +

View File

@ -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<WorkingHoursTimeFrame> => {
): TimeFrame[] => {
let initialTime = startTimeMinutes;
const finalizationTime = endTimeMinutes;
const result = [] as Array<WorkingHoursTimeFrame>;
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<WorkingHoursTimeFrame>;
// 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<string, boolean> = {};
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;

View File

@ -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,
})),
],
},
},
},

View File

@ -138,6 +138,7 @@ async function getEventType(ctx: { prisma: typeof prisma }, input: z.infer<typeo
},
availability: {
select: {
date: true,
startTime: true,
endTime: true,
days: true,
@ -227,11 +228,12 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, 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<typeof getScheduleSchema>, 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<string, Slot[]> = {};
const availabilityCheckProps = {
eventLength: eventType.length,
@ -284,6 +289,7 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, 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<typeof getScheduleSchema>, 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();

View File

@ -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<HTMLDivElement, DialogContentProps
: props.size == "lg"
? "p-8 sm:max-w-[70rem]"
: props.size == "md"
? "p-8 sm:max-w-[40rem]"
? "p-8 sm:max-w-[48rem]"
: "p-8 sm:max-w-[35rem]",
"max-h-[560px] overflow-visible overscroll-auto md:h-auto md:max-h-[inherit]",
`${props.className || ""}`

View File

@ -17,7 +17,7 @@ export type DatePickerProps = {
/** Fires when the month is changed. */
onMonthChange?: (date: Dayjs) => 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 (
<button
style={props.disabled ? { ...disabledDateButtonEmbedStyles } : { ...enabledDateButtonEmbedStyles }}
type="button"
style={disabled ? { ...disabledDateButtonEmbedStyles } : { ...enabledDateButtonEmbedStyles }}
className={classNames(
"disabled:text-bookinglighter dark:hover:border-darkmodebrand absolute top-0 left-0 right-0 bottom-0 mx-auto w-full rounded-md border-2 border-transparent text-center font-medium disabled:cursor-default disabled:border-transparent disabled:font-light disabled:dark:border-transparent",
active
? "dark:bg-darkmodebrand dark:text-darkmodebrandcontrast bg-brand text-brandcontrast border-2"
: !props.disabled
: !disabled
? "dark:bg-darkgray-200 bg-gray-100 hover:bg-gray-300 dark:text-white"
: ""
)}
data-testid="day"
data-disabled={props.disabled}
data-disabled={disabled}
disabled={disabled}
{...props}>
{date.date()}
{date.isToday() && (
<span className="absolute left-0 bottom-0 mx-auto -mb-px w-full text-4xl md:-bottom-1 lg:bottom-0">
.
</span>
<span className="absolute left-0 right-0 bottom-0 h-2/5 align-middle text-4xl leading-[0rem]">.</span>
)}
</button>
);
};
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 = ({
</span>
<div className="text-black dark:text-white">
<button
type="button"
onClick={() => changeMonth(-1)}
className={classNames(
"group p-1 opacity-50 hover:opacity-100 ltr:mr-2 rtl:ml-2",
@ -172,6 +198,7 @@ const DatePicker = ({
<ChevronLeftIcon className="h-5 w-5" />
</button>
<button
type="button"
className="group p-1 opacity-50 hover:opacity-100"
onClick={() => changeMonth(+1)}
data-testid="incrementMonth">