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:
parent
8fd5d6b5b5
commit
2f2b72dd54
|
@ -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",
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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: [],
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
# Availability related code will live here
|
||||
|
||||
- [ ] Maybe migrate `getBusyTimes` here
|
||||
- [ ] Maybe migrate `getUserAvailability` here (or into `users` feature)
|
|
@ -195,6 +195,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
|
|||
},
|
||||
availability: {
|
||||
select: {
|
||||
date: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
days: true,
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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}
|
||||
|
|
|
@ -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")}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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 +
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
})),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 || ""}`
|
||||
|
|
|
@ -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">
|
||||
|
|
Loading…
Reference in New Issue
Block a user