feat: travel schedules to schedule timezone changes (#14512)
* add Travel Schedule modal * UI to list schedules * set shouldDirty * add backend to add new travelSchedule * implement deleting a schedule * check if schedule is overlapping * WIP * fix finding overlapping travel schedule * only use travelSchedule when default availability is used * adjust date overrides to timezone schedule * first version of changeTimeZone cron job * fix tests by adding travelSchedules * fixes for cron job api call * improve unit tests * add migration * fix type error * fix collective-scheduling test * clean up cron job * code clean up * code clean up * show timezone from travel schedule for date override * add date override tests * show tz on date override only in default schedule * fix deleting old schedules * minor fixes in cron api handler * code clean up * code clean up from feedback * fix asia/kalkota comment * fix dark mode * fix start and end date conversion to utc * add first unit test for travel schedules * Fix modal render issue * show timezone city wtihout _ * fix dark more for datepicker * reset values after closing dialog * remove session from middleware * exit loop early once schedule is found * fix type error * add getTravelSchedules handler * clean up DatePicker * fix type error * code clean up * code clean up * add indexes * use deleteMany * fix icons --------- Co-authored-by: CarinaWolli <wollencarina@gmail.com> Co-authored-by: Alex van Andel <me@alexvanandel.com> Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
This commit is contained in:
26 files changed
+873
-33
No files matched your search
@@ -0,0 +1,24 @@
|
||||
name: Cron - changeTimeZone
|
||||
|
||||
on:
|
||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||
schedule:
|
||||
# Runs "At every full hour." (see https://crontab.guru)
|
||||
- cron: "0 * * * *"
|
||||
|
||||
jobs:
|
||||
cron-scheduleEmailReminders:
|
||||
env:
|
||||
APP_URL: ${{ secrets.APP_URL }}
|
||||
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: cURL request
|
||||
if: ${{ env.APP_URL && env.CRON_API_KEY }}
|
||||
run: |
|
||||
curl ${{ secrets.APP_URL }}/api/cron/changeTimeZone \
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
-sSf
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { FormValues } from "@pages/settings/my-account/general";
|
||||
import { useState } from "react";
|
||||
import type { UseFormSetValue } from "react-hook-form";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib/timePreferences";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
Button,
|
||||
Label,
|
||||
DateRangePicker,
|
||||
TimezoneSelect,
|
||||
SettingsToggle,
|
||||
DatePicker,
|
||||
} from "@calcom/ui";
|
||||
|
||||
interface TravelScheduleModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
setValue: UseFormSetValue<FormValues>;
|
||||
existingSchedules: FormValues["travelSchedules"];
|
||||
}
|
||||
|
||||
const TravelScheduleModal = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
setValue,
|
||||
existingSchedules,
|
||||
}: TravelScheduleModalProps) => {
|
||||
const { t } = useLocale();
|
||||
const { timezone: preferredTimezone } = useTimePreferences();
|
||||
|
||||
const [startDate, setStartDate] = useState<Date>(new Date());
|
||||
const [endDate, setEndDate] = useState<Date | undefined>(new Date());
|
||||
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState(preferredTimezone);
|
||||
const [isNoEndDate, setIsNoEndDate] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const isOverlapping = (newSchedule: { startDate: Date; endDate?: Date }) => {
|
||||
const newStart = dayjs(newSchedule.startDate);
|
||||
const newEnd = newSchedule.endDate ? dayjs(newSchedule.endDate) : null;
|
||||
|
||||
for (const schedule of existingSchedules) {
|
||||
const start = dayjs(schedule.startDate);
|
||||
const end = schedule.endDate ? dayjs(schedule.endDate) : null;
|
||||
|
||||
if (!newEnd) {
|
||||
// if the start date is after or on the existing schedule's start date and before the existing schedule's end date (if it has one)
|
||||
if (newStart.isSame(start) || newStart.isAfter(start)) {
|
||||
if (!end || newStart.isSame(end) || newStart.isBefore(end)) return true;
|
||||
}
|
||||
} else {
|
||||
// For schedules with an end date, check for any overlap
|
||||
if (newStart.isSame(end) || newStart.isBefore(end) || end === null) {
|
||||
if (newEnd.isSame(start) || newEnd.isAfter(start)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetValues = () => {
|
||||
setStartDate(new Date());
|
||||
setEndDate(new Date());
|
||||
setSelectedTimeZone(preferredTimezone);
|
||||
setIsNoEndDate(false);
|
||||
};
|
||||
|
||||
const createNewSchedule = () => {
|
||||
const newSchedule = {
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone: selectedTimeZone,
|
||||
};
|
||||
|
||||
if (!isOverlapping(newSchedule)) {
|
||||
setValue("travelSchedules", existingSchedules.concat(newSchedule), { shouldDirty: true });
|
||||
onOpenChange();
|
||||
resetValues();
|
||||
} else {
|
||||
setErrorMessage(t("overlaps_with_existing_schedule"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
title={t("travel_schedule")}
|
||||
description={t("travel_schedule_description")}
|
||||
type="creation">
|
||||
<div>
|
||||
{!isNoEndDate ? (
|
||||
<>
|
||||
<Label className="mt-2">{t("time_range")}</Label>
|
||||
<DateRangePicker
|
||||
startDate={startDate}
|
||||
endDate={endDate ?? startDate}
|
||||
onDatesChange={({ startDate: newStartDate, endDate: newEndDate }) => {
|
||||
setStartDate(newStartDate);
|
||||
setEndDate(newEndDate);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Label className="mt-2">{t("date")}</Label>
|
||||
<DatePicker
|
||||
minDate={new Date()}
|
||||
date={startDate}
|
||||
className="w-56"
|
||||
onDatesChange={(newDate) => {
|
||||
setStartDate(newDate);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="text-error mt-1 text-sm">{errorMessage}</div>
|
||||
<div className="mt-3">
|
||||
<SettingsToggle
|
||||
labelClassName="mt-1 font-normal"
|
||||
title={t("schedule_tz_without_end_date")}
|
||||
checked={isNoEndDate}
|
||||
onCheckedChange={(e) => {
|
||||
setEndDate(!e ? startDate : undefined);
|
||||
setIsNoEndDate(e);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Label className="mt-6">{t("timezone")}</Label>
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={({ value }) => setSelectedTimeZone(value)}
|
||||
className="mb-11 mt-2 w-full rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter showDivider className="relative">
|
||||
<DialogClose />
|
||||
<Button
|
||||
onClick={() => {
|
||||
createNewSchedule();
|
||||
}}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default TravelScheduleModal;
|
||||
@@ -16,3 +16,12 @@ export type WorkingHours = {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
|
||||
export type TravelSchedule = {
|
||||
id: number;
|
||||
timeZone: string;
|
||||
userId: number;
|
||||
startDate: Date;
|
||||
endDate: Date | null;
|
||||
prevTimeZone: string | null;
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { getDefaultScheduleId } from "@calcom/trpc/server/routers/viewer/availability/util";
|
||||
|
||||
const travelScheduleSelect = {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
timeZone: true,
|
||||
prevTimeZone: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
timeZone: true,
|
||||
defaultScheduleId: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const apiKey = req.headers.authorization || req.query.apiKey;
|
||||
if (process.env.CRON_API_KEY !== apiKey) {
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.status(405).json({ message: "Invalid method" });
|
||||
return;
|
||||
}
|
||||
|
||||
let timeZonesChanged = 0;
|
||||
|
||||
const setNewTimeZone = async (timeZone: string, user: { id: number; defaultScheduleId: number | null }) => {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
timeZone: timeZone,
|
||||
},
|
||||
});
|
||||
|
||||
const defaultScheduleId = await getDefaultScheduleId(user.id, prisma);
|
||||
|
||||
if (!user.defaultScheduleId) {
|
||||
// set default schedule if not already set
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
defaultScheduleId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.schedule.updateMany({
|
||||
where: {
|
||||
id: defaultScheduleId,
|
||||
},
|
||||
data: {
|
||||
timeZone: timeZone,
|
||||
},
|
||||
});
|
||||
timeZonesChanged++;
|
||||
};
|
||||
|
||||
/* travelSchedules should be deleted automatically when timezone is set back to original tz,
|
||||
but we do this in case there cron job didn't run for some reason
|
||||
*/
|
||||
const schedulesToDelete = await prisma.travelSchedule.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
startDate: {
|
||||
lt: dayjs.utc().subtract(2, "day").toDate(),
|
||||
},
|
||||
endDate: null,
|
||||
},
|
||||
{
|
||||
endDate: {
|
||||
lt: dayjs.utc().subtract(2, "day").toDate(),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: travelScheduleSelect,
|
||||
});
|
||||
|
||||
for (const travelSchedule of schedulesToDelete) {
|
||||
if (travelSchedule.prevTimeZone) {
|
||||
await setNewTimeZone(travelSchedule.prevTimeZone, travelSchedule.user);
|
||||
}
|
||||
await prisma.travelSchedule.delete({
|
||||
where: {
|
||||
id: travelSchedule.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const travelSchedulesCloseToCurrentDate = await prisma.travelSchedule.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
startDate: {
|
||||
gte: dayjs.utc().subtract(1, "day").toDate(),
|
||||
lte: dayjs.utc().add(1, "day").toDate(),
|
||||
},
|
||||
},
|
||||
{
|
||||
endDate: {
|
||||
gte: dayjs.utc().subtract(1, "day").toDate(),
|
||||
lte: dayjs.utc().add(1, "day").toDate(),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: travelScheduleSelect,
|
||||
});
|
||||
|
||||
const travelScheduleIdsToDelete = [];
|
||||
|
||||
for (const travelSchedule of travelSchedulesCloseToCurrentDate) {
|
||||
const userTz = travelSchedule.user.timeZone;
|
||||
const offset = dayjs().tz(userTz).utcOffset();
|
||||
|
||||
// midnight of user's time zone in utc time
|
||||
const startDateUTC = dayjs(travelSchedule.startDate).subtract(offset, "minute");
|
||||
// 23:59 of user's time zone in utc time
|
||||
const endDateUTC = dayjs(travelSchedule.endDate).subtract(offset, "minute");
|
||||
if (
|
||||
!dayjs.utc().isBefore(startDateUTC) &&
|
||||
dayjs.utc().isBefore(endDateUTC) &&
|
||||
!travelSchedule.prevTimeZone
|
||||
) {
|
||||
// if travel schedule has started and prevTimeZone is not yet set, we need to change time zone
|
||||
await setNewTimeZone(travelSchedule.timeZone, travelSchedule.user);
|
||||
|
||||
if (!travelSchedule.endDate) {
|
||||
travelScheduleIdsToDelete.push(travelSchedule.id);
|
||||
} else {
|
||||
await prisma.travelSchedule.update({
|
||||
where: {
|
||||
id: travelSchedule.id,
|
||||
},
|
||||
data: {
|
||||
prevTimeZone: travelSchedule.user.timeZone,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!dayjs.utc().isBefore(endDateUTC)) {
|
||||
if (travelSchedule.prevTimeZone) {
|
||||
// travel schedule ended, change back to original timezone
|
||||
await setNewTimeZone(travelSchedule.prevTimeZone, travelSchedule.user);
|
||||
}
|
||||
travelScheduleIdsToDelete.push(travelSchedule.id);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.travelSchedule.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: travelScheduleIdsToDelete,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).json({ timeZonesChanged });
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { formatLocalizedDateTime } from "@calcom/lib/date-fns";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { localeOptions } from "@calcom/lib/i18n";
|
||||
import { nameOfDay } from "@calcom/lib/weekday";
|
||||
@@ -26,6 +28,29 @@ import {
|
||||
} from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import TravelScheduleModal from "@components/settings/TravelScheduleModal";
|
||||
|
||||
export type FormValues = {
|
||||
locale: {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
timeZone: string;
|
||||
timeFormat: {
|
||||
value: number;
|
||||
label: string | number;
|
||||
};
|
||||
weekStart: {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
travelSchedules: {
|
||||
id?: number;
|
||||
startDate: Date;
|
||||
endDate?: Date;
|
||||
timeZone: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
@@ -46,24 +71,34 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
|
||||
interface GeneralViewProps {
|
||||
localeProp: string;
|
||||
user: RouterOutputs["viewer"]["me"];
|
||||
travelSchedules: RouterOutputs["viewer"]["getTravelSchedules"];
|
||||
}
|
||||
|
||||
const GeneralQueryView = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { data: user, isPending } = trpc.viewer.me.useQuery();
|
||||
if (isPending) return <SkeletonLoader title={t("general")} description={t("general_description")} />;
|
||||
|
||||
const { data: travelSchedules, isPending: isPendingTravelSchedules } =
|
||||
trpc.viewer.getTravelSchedules.useQuery();
|
||||
|
||||
if (isPending || isPendingTravelSchedules)
|
||||
return <SkeletonLoader title={t("general")} description={t("general_description")} />;
|
||||
if (!user) {
|
||||
throw new Error(t("something_went_wrong"));
|
||||
}
|
||||
return <GeneralView user={user} localeProp={user.locale} />;
|
||||
return <GeneralView user={user} travelSchedules={travelSchedules || []} localeProp={user.locale} />;
|
||||
};
|
||||
|
||||
const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
||||
const utils = trpc.useUtils();
|
||||
const { t } = useLocale();
|
||||
const GeneralView = ({ localeProp, user, travelSchedules }: GeneralViewProps) => {
|
||||
const utils = trpc.useContext();
|
||||
const {
|
||||
t,
|
||||
i18n: { language },
|
||||
} = useLocale();
|
||||
const { update } = useSession();
|
||||
const [isUpdateBtnLoading, setIsUpdateBtnLoading] = useState<boolean>(false);
|
||||
const [isTZScheduleOpen, setIsTZScheduleOpen] = useState<boolean>(false);
|
||||
|
||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async (res) => {
|
||||
@@ -100,7 +135,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
||||
{ value: "Saturday", label: nameOfDay(localeProp, 6) },
|
||||
];
|
||||
|
||||
const formMethods = useForm({
|
||||
const formMethods = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
locale: {
|
||||
value: localeProp || "",
|
||||
@@ -115,6 +150,15 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
||||
value: user.weekStart,
|
||||
label: nameOfDay(localeProp, user.weekStart === "Sunday" ? 0 : 1),
|
||||
},
|
||||
travelSchedules:
|
||||
travelSchedules.map((schedule) => {
|
||||
return {
|
||||
id: schedule.id,
|
||||
startDate: schedule.startDate,
|
||||
endDate: schedule.endDate ?? undefined,
|
||||
timeZone: schedule.timeZone,
|
||||
};
|
||||
}) || [],
|
||||
},
|
||||
});
|
||||
const {
|
||||
@@ -132,6 +176,8 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
||||
!!user.receiveMonthlyDigestEmail
|
||||
);
|
||||
|
||||
const watchedTzSchedules = formMethods.watch("travelSchedules");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form
|
||||
@@ -181,6 +227,64 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{!watchedTzSchedules.length ? (
|
||||
<Button color="minimal" className="mt-2" onClick={() => setIsTZScheduleOpen(true)}>
|
||||
{t("schedule_timezone_change")}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="bg-muted border-subtle mt-2 rounded-md border p-4">
|
||||
<Label>{t("travel_schedule")}</Label>
|
||||
<div className="dark:bg-darkgray-100 border-subtle mt-4 rounded-md border bg-white text-sm">
|
||||
{watchedTzSchedules.map((schedule, index) => {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex items-center p-4",
|
||||
index !== 0 ? "border-subtle border-t" : ""
|
||||
)}
|
||||
key={index}>
|
||||
<div>
|
||||
<div className="text-emphasis font-semibold">{`${formatLocalizedDateTime(
|
||||
schedule.startDate,
|
||||
{ day: "numeric", month: "long" },
|
||||
language
|
||||
)} ${
|
||||
schedule.endDate
|
||||
? `- ${formatLocalizedDateTime(
|
||||
schedule.endDate,
|
||||
{ day: "numeric", month: "long" },
|
||||
language
|
||||
)}`
|
||||
: ``
|
||||
}`}</div>
|
||||
<div className="text-subtle">{schedule.timeZone.replace(/_/g, " ")}</div>
|
||||
</div>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="ml-auto"
|
||||
variant="icon"
|
||||
StartIcon="trash-2"
|
||||
onClick={() => {
|
||||
const updatedSchedules = watchedTzSchedules.filter(
|
||||
(s, filterIndex) => filterIndex !== index
|
||||
);
|
||||
formMethods.setValue("travelSchedules", updatedSchedules, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
StartIcon="plus"
|
||||
color="secondary"
|
||||
className="mt-4"
|
||||
onClick={() => setIsTZScheduleOpen(true)}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
name="timeFormat"
|
||||
control={formMethods.control}
|
||||
@@ -267,6 +371,12 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
||||
}}
|
||||
switchContainerClassName="mt-6"
|
||||
/>
|
||||
<TravelScheduleModal
|
||||
open={isTZScheduleOpen}
|
||||
onOpenChange={() => setIsTZScheduleOpen(false)}
|
||||
setValue={formMethods.setValue}
|
||||
existingSchedules={formMethods.getValues("travelSchedules") ?? []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2318,6 +2318,11 @@
|
||||
"field_identifiers_as_variables": "Use field identifiers as variables for your custom event redirect",
|
||||
"field_identifiers_as_variables_with_example": "Use field identifiers as variables for your custom event redirect (e.g. {{variable}})",
|
||||
"account_already_linked": "Account is already linked",
|
||||
"travel_schedule": "Travel Schedule",
|
||||
"travel_schedule_description": "Plan your trip ahead to keep your existing schedule in a different timezone and prevent being booked at midnight.",
|
||||
"schedule_timezone_change": "Schedule timezone change",
|
||||
"date": "Date",
|
||||
"overlaps_with_existing_schedule": "This overlaps with an existing schedule. Please select a different date.",
|
||||
|
||||
"email_team_invite|subject|added_to_org": "{{user}} added you to the organization {{team}} on {{appName}}",
|
||||
"email_team_invite|subject|invited_to_org": "{{user}} invited you to join the organization {{team}} on {{appName}}",
|
||||
@@ -2354,6 +2359,7 @@
|
||||
"confirm_email": "Confirm your email",
|
||||
"confirm_email_description": "We sent an email to <strong>{{email}}</strong>. Click the link in the email to verify this address.",
|
||||
"send_event_details_to": "Send event details to",
|
||||
"schedule_tz_without_end_date": "Schedule timezone without end date",
|
||||
"select_members": "Select members",
|
||||
"lock_event_types_modal_header": "What should we do with your member's existing event types?",
|
||||
"org_delete_event_types_org_admin": "All of your members individual event types (except managed ones) will be permanently deleted. They will not be able to create new ones",
|
||||
|
||||
@@ -66,6 +66,7 @@ const _getEventType = async (id: number) => {
|
||||
metadata: true,
|
||||
schedule: {
|
||||
select: {
|
||||
id: true,
|
||||
availability: {
|
||||
select: {
|
||||
days: true,
|
||||
@@ -276,6 +277,8 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
|
||||
const useHostSchedulesForTeamEvent = eventType?.metadata?.config?.useHostSchedulesForTeamEvent;
|
||||
const schedule = !useHostSchedulesForTeamEvent && eventType?.schedule ? eventType.schedule : userSchedule;
|
||||
|
||||
const isDefaultSchedule = userSchedule && userSchedule.id === schedule.id;
|
||||
|
||||
log.debug(
|
||||
"Using schedule:",
|
||||
safeStringify({
|
||||
@@ -344,6 +347,15 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
|
||||
dateTo,
|
||||
availability,
|
||||
timeZone,
|
||||
travelSchedules: isDefaultSchedule
|
||||
? user.travelSchedules.map((schedule) => {
|
||||
return {
|
||||
startDate: dayjs(schedule.startDate),
|
||||
endDate: schedule.endDate ? dayjs(schedule.endDate) : undefined,
|
||||
timeZone: schedule.timeZone,
|
||||
};
|
||||
})
|
||||
: [],
|
||||
outOfOffice: datesOutOfOffice,
|
||||
});
|
||||
|
||||
|
||||
@@ -225,6 +225,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
timeZone: true,
|
||||
schedule: {
|
||||
select: {
|
||||
id: true,
|
||||
availability: true,
|
||||
timeZone: true,
|
||||
},
|
||||
@@ -290,7 +291,6 @@ const loadUsers = async (eventType: NewBookingEventType, dynamicUserList: string
|
||||
usernameList: dynamicUserList,
|
||||
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
|
||||
});
|
||||
|
||||
return users;
|
||||
}
|
||||
const hosts = eventType.hosts || [];
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import type { TimeRange, WorkingHours } from "@calcom/types/schedule";
|
||||
import { Button, DialogTrigger, Icon, Tooltip } from "@calcom/ui";
|
||||
|
||||
@@ -12,6 +14,7 @@ const sortByDate = (a: { ranges: TimeRange[]; id: string }, b: { ranges: TimeRan
|
||||
const DateOverrideList = ({
|
||||
workingHours,
|
||||
excludedDates = [],
|
||||
travelSchedules = [],
|
||||
userTimeFormat,
|
||||
hour12,
|
||||
replace,
|
||||
@@ -24,6 +27,7 @@ const DateOverrideList = ({
|
||||
excludedDates?: string[];
|
||||
userTimeFormat: number | null;
|
||||
hour12: boolean;
|
||||
travelSchedules?: RouterOutputs["viewer"]["getTravelSchedules"];
|
||||
}) => {
|
||||
const { t, i18n } = useLocale();
|
||||
|
||||
@@ -62,7 +66,17 @@ const DateOverrideList = ({
|
||||
) : (
|
||||
item.ranges.map((range, i) => (
|
||||
<p key={i} className="text-subtle text-xs">
|
||||
{timeSpan(range)}
|
||||
{`${timeSpan(range)} ${
|
||||
travelSchedules
|
||||
.find(
|
||||
(travelSchedule) =>
|
||||
!dayjs(item.ranges[0].start).isBefore(travelSchedule.startDate) &&
|
||||
(!dayjs(item.ranges[0].end).isAfter(travelSchedule.endDate) ||
|
||||
!travelSchedule.endDate)
|
||||
)
|
||||
?.timeZone.replace(/_/g, " ") || ""
|
||||
}`}
|
||||
<></>
|
||||
</p>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("processWorkingHours", () => {
|
||||
const dateFrom = dayjs.utc().startOf("day").day(2).add(1, "week");
|
||||
const dateTo = dayjs.utc().endOf("day").day(3).add(1, "week");
|
||||
|
||||
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo });
|
||||
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo, travelSchedules: [] });
|
||||
|
||||
expect(results.length).toBe(2); // There should be two working days between the range
|
||||
// "America/New_York" day shifts -1, so we need to add a day to correct this shift.
|
||||
@@ -42,7 +42,7 @@ describe("processWorkingHours", () => {
|
||||
const dateFrom = dayjs().month(9).date(24); // starts before DST change
|
||||
const dateTo = dayjs().startOf("day").month(10).date(1); // first day of November
|
||||
|
||||
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo });
|
||||
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo, travelSchedules: [] });
|
||||
|
||||
const lastAvailableSlot = results[results.length - 1];
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("processWorkingHours", () => {
|
||||
const dateFrom = dayjs();
|
||||
const dateTo = dayjs().endOf("month");
|
||||
|
||||
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo });
|
||||
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo, travelSchedules: [] });
|
||||
|
||||
expect(results).toStrictEqual([
|
||||
{
|
||||
@@ -167,7 +167,7 @@ describe("processWorkingHours", () => {
|
||||
const dateFrom = dayjs().month(10).date(1).startOf("day");
|
||||
const dateTo = dayjs().month(10).endOf("month");
|
||||
|
||||
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo });
|
||||
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo, travelSchedules: [] });
|
||||
|
||||
const allDSTStartAt12 = results
|
||||
.filter((res) => res.start.isBefore(firstSundayOfNovember))
|
||||
@@ -191,7 +191,7 @@ describe("processWorkingHours", () => {
|
||||
const dateFrom = dayjs("2023-11-07T00:00:00Z").tz(timeZone); // 2023-11-07T00:00:00 (America/New_York)
|
||||
const dateTo = dayjs("2023-11-08T00:00:00Z").tz(timeZone); // 2023-11-08T00:00:00 (America/New_York)
|
||||
|
||||
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo });
|
||||
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo, travelSchedules: [] });
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
@@ -207,10 +207,85 @@ describe("processWorkingHours", () => {
|
||||
const dateFrom = dayjs("2023-11-07T00:00:00Z").tz(timeZone); // 2023-11-07T00:00:00 (America/New_York)
|
||||
const dateTo = dayjs("2023-11-07T23:59:59Z").tz(timeZone); // 2023-11-07T23:59:59 (America/New_York)
|
||||
|
||||
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo });
|
||||
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo, travelSchedules: [] });
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("should show working hours in correct timezone with existing travel schedules", () => {
|
||||
vi.useFakeTimers().setSystemTime(new Date("2023-01-01T00:00:00.000Z"));
|
||||
|
||||
const item = {
|
||||
days: [0, 1, 2, 3, 4, 5, 6], // Monday to Sunday
|
||||
startTime: new Date(Date.UTC(2023, 5, 12, 8, 0)), // 8 AM
|
||||
endTime: new Date(Date.UTC(2023, 5, 12, 17, 0)), // 5 PM
|
||||
};
|
||||
|
||||
const timeZone = "Europe/Berlin";
|
||||
|
||||
const dateFrom = dayjs().startOf("day");
|
||||
const dateTo = dayjs().add(1, "week").startOf("day");
|
||||
|
||||
const travelSchedules = [
|
||||
{
|
||||
startDate: dayjs().add(2, "day").startOf("day"),
|
||||
endDate: dayjs().add(3, "day").endOf("day"),
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
{
|
||||
startDate: dayjs().add(5, "day").startOf("day"),
|
||||
timeZone: "Asia/Kolkata",
|
||||
},
|
||||
];
|
||||
|
||||
const resultsWithTravelSchedule = processWorkingHours({
|
||||
item,
|
||||
timeZone,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
travelSchedules,
|
||||
});
|
||||
|
||||
const resultWithOriginalTz = resultsWithTravelSchedule.filter((result) => {
|
||||
return (
|
||||
result.start.isBefore(travelSchedules[0].startDate) ||
|
||||
(result.start.isAfter(travelSchedules[0].endDate) &&
|
||||
result.start.isBefore(travelSchedules[1].startDate))
|
||||
);
|
||||
});
|
||||
|
||||
const resultsWithNewYorkTz = resultsWithTravelSchedule.filter(
|
||||
(result) =>
|
||||
!result.start.isBefore(travelSchedules[0].startDate) &&
|
||||
!result.start.isAfter(travelSchedules[0].endDate)
|
||||
);
|
||||
const resultsWithKolkataTz = resultsWithTravelSchedule.filter(
|
||||
(result) => !result.start.isBefore(travelSchedules[1].startDate)
|
||||
);
|
||||
|
||||
// 8AM in Europe/Berlin is 7AM in UTC, 5PM in Europe/Berlin is 4PM in UTC
|
||||
expect(
|
||||
resultWithOriginalTz.every(
|
||||
(result) => result.start.utc().hour() === 7 && result.end.utc().hour() === 16
|
||||
)
|
||||
).toBeTruthy();
|
||||
|
||||
// 8AM in America/New_York is 1PM in UTC, 5PM in America/New_York is 10PM in UTC
|
||||
expect(
|
||||
resultsWithNewYorkTz.every((result) => {
|
||||
return result.start.utc().hour() === 13 && result.end.utc().hour() === 22;
|
||||
})
|
||||
).toBeTruthy();
|
||||
|
||||
// 8AM in Asia/Kolkata is 2:30AM in UTC, 5PM in Asia/Kolkata is 11:30AM in UTC
|
||||
expect(
|
||||
resultsWithKolkataTz.every((result) => {
|
||||
const correctStartTime = result.start.utc().hour() === 2 && result.start.utc().minute() === 30;
|
||||
const correctEndTime = result.end.utc().hour() === 11 && result.end.utc().minute() === 30;
|
||||
return correctStartTime && correctEndTime;
|
||||
})
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("processDateOverrides", () => {
|
||||
@@ -224,11 +299,46 @@ describe("processDateOverrides", () => {
|
||||
// 2023-06-12T20:00:00-04:00 (America/New_York)
|
||||
const timeZone = "America/New_York";
|
||||
|
||||
const result = processDateOverride({ item, itemDateAsUtc: dayjs.utc(item.date), timeZone });
|
||||
const result = processDateOverride({
|
||||
item,
|
||||
itemDateAsUtc: dayjs.utc(item.date),
|
||||
timeZone,
|
||||
travelSchedules: [],
|
||||
});
|
||||
|
||||
expect(result.start.format()).toEqual(dayjs("2023-06-12T12:00:00Z").tz(timeZone).format());
|
||||
expect(result.end.format()).toEqual(dayjs("2023-06-12T21:00:00Z").tz(timeZone).format());
|
||||
});
|
||||
it("should show date overrides in correct timezone with existing travel schedules", () => {
|
||||
const item = {
|
||||
date: new Date(Date.UTC(2023, 5, 12, 8, 0)),
|
||||
startTime: new Date(Date.UTC(2023, 5, 12, 8, 0)), // 8 AM
|
||||
endTime: new Date(Date.UTC(2023, 5, 12, 17, 0)), // 5 PM
|
||||
};
|
||||
|
||||
// 2023-06-12T20:00:00-04:00 (America/New_York)
|
||||
const timeZone = "America/New_York";
|
||||
|
||||
const travelScheduleTz = "Europe/Berlin";
|
||||
|
||||
const travelSchedules = [
|
||||
{
|
||||
startDate: dayjs(new Date(Date.UTC(2023, 5, 11, 8, 0))).startOf("day"),
|
||||
endDate: dayjs(new Date(Date.UTC(2023, 5, 15, 8, 0))).endOf("day"),
|
||||
timeZone: travelScheduleTz,
|
||||
},
|
||||
];
|
||||
|
||||
const result = processDateOverride({
|
||||
item,
|
||||
itemDateAsUtc: dayjs.utc(item.date),
|
||||
timeZone,
|
||||
travelSchedules: travelSchedules,
|
||||
});
|
||||
|
||||
expect(result.start.format()).toEqual(dayjs("2023-06-12T06:00:00Z").tz(travelScheduleTz).format());
|
||||
expect(result.end.format()).toEqual(dayjs("2023-06-12T15:00:00Z").tz(travelScheduleTz).format());
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDateRanges", () => {
|
||||
@@ -251,7 +361,13 @@ describe("buildDateRanges", () => {
|
||||
|
||||
const timeZone = "America/New_York";
|
||||
|
||||
const { dateRanges: results } = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo });
|
||||
const { dateRanges: results } = buildDateRanges({
|
||||
availability: items,
|
||||
timeZone,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
travelSchedules: [],
|
||||
});
|
||||
// [
|
||||
// { s: '2023-06-13T10:00:00-04:00', e: '2023-06-13T15:00:00-04:00' },
|
||||
// { s: '2023-06-14T08:00:00-04:00', e: '2023-06-14T17:00:00-04:00' }
|
||||
@@ -287,8 +403,15 @@ describe("buildDateRanges", () => {
|
||||
|
||||
const dateFrom = dayjs.tz("2023-08-15", "Europe/Brussels").startOf("day");
|
||||
const dateTo = dayjs.tz("2023-08-15", "Europe/Brussels").endOf("day");
|
||||
//add tarvelScheduels
|
||||
|
||||
const { dateRanges: result } = buildDateRanges({ availability: item, timeZone, dateFrom, dateTo });
|
||||
const { dateRanges: result } = buildDateRanges({
|
||||
availability: item,
|
||||
timeZone,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
travelSchedules: [],
|
||||
});
|
||||
// this happened only on Europe/Brussels, Europe/Amsterdam was 2023-08-15T17:00:00-10:00 (as it should be)
|
||||
expect(result[0].end.format()).not.toBe("2023-08-14T17:00:00-10:00");
|
||||
});
|
||||
@@ -309,8 +432,15 @@ describe("buildDateRanges", () => {
|
||||
|
||||
const dateFrom = dayjs("2023-06-13T00:00:00Z");
|
||||
const dateTo = dayjs("2023-06-15T00:00:00Z");
|
||||
//add tarvelScheduels
|
||||
|
||||
const { dateRanges: results } = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo });
|
||||
const { dateRanges: results } = buildDateRanges({
|
||||
availability: items,
|
||||
timeZone,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
travelSchedules: [],
|
||||
});
|
||||
|
||||
expect(results[0]).toEqual({
|
||||
start: dayjs("2023-06-14T07:00:00Z").tz(timeZone),
|
||||
@@ -329,8 +459,15 @@ describe("buildDateRanges", () => {
|
||||
|
||||
const dateFrom = dayjs("2023-06-13T10:00:00Z");
|
||||
const dateTo = dayjs("2023-06-13T10:30:00Z");
|
||||
//add tarvelScheduels
|
||||
|
||||
const { dateRanges: results } = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo });
|
||||
const { dateRanges: results } = buildDateRanges({
|
||||
availability: items,
|
||||
timeZone,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
travelSchedules: [],
|
||||
});
|
||||
|
||||
expect(results[0]).toEqual({
|
||||
start: dayjs("2023-06-13T08:00:00Z").tz(timeZone),
|
||||
@@ -354,8 +491,15 @@ describe("buildDateRanges", () => {
|
||||
|
||||
const dateFrom = dayjs("2023-06-13T00:00:00Z");
|
||||
const dateTo = dayjs("2023-06-15T00:00:00Z");
|
||||
//add tarvelScheduels
|
||||
|
||||
const { dateRanges: results } = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo });
|
||||
const { dateRanges: results } = buildDateRanges({
|
||||
availability: items,
|
||||
timeZone,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
travelSchedules: [],
|
||||
});
|
||||
|
||||
expect(results[0]).toEqual({
|
||||
start: dayjs("2023-06-14T02:00:00Z").tz(timeZone),
|
||||
@@ -396,6 +540,7 @@ describe("buildDateRanges", () => {
|
||||
timeZone,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
travelSchedules: [],
|
||||
outOfOffice,
|
||||
});
|
||||
|
||||
|
||||
@@ -11,25 +11,47 @@ export type DateRange = {
|
||||
export type DateOverride = Pick<Availability, "date" | "startTime" | "endTime">;
|
||||
export type WorkingHours = Pick<Availability, "days" | "startTime" | "endTime">;
|
||||
|
||||
type TravelSchedule = { startDate: Dayjs; endDate?: Dayjs; timeZone: string };
|
||||
|
||||
function getAdjustedTimezone(date: Dayjs, timeZone: string, travelSchedules: TravelSchedule[]) {
|
||||
let adjustedTimezone = timeZone;
|
||||
|
||||
for (const travelSchedule of travelSchedules) {
|
||||
if (
|
||||
!date.isBefore(travelSchedule.startDate) &&
|
||||
(!travelSchedule.endDate || !date.isAfter(travelSchedule.endDate))
|
||||
) {
|
||||
adjustedTimezone = travelSchedule.timeZone;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return adjustedTimezone;
|
||||
}
|
||||
|
||||
export function processWorkingHours({
|
||||
item,
|
||||
timeZone,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
travelSchedules,
|
||||
}: {
|
||||
item: WorkingHours;
|
||||
timeZone: string;
|
||||
dateFrom: Dayjs;
|
||||
dateTo: Dayjs;
|
||||
travelSchedules: TravelSchedule[];
|
||||
}) {
|
||||
const utcDateTo = dateTo.utc();
|
||||
const results = [];
|
||||
for (let date = dateFrom.startOf("day"); utcDateTo.isAfter(date); date = date.add(1, "day")) {
|
||||
const fromOffset = dateFrom.startOf("day").utcOffset();
|
||||
const offset = date.tz(timeZone).utcOffset();
|
||||
|
||||
const adjustedTimezone = getAdjustedTimezone(date, timeZone, travelSchedules);
|
||||
|
||||
const offset = date.tz(adjustedTimezone).utcOffset();
|
||||
|
||||
// it always has to be start of the day (midnight) even when DST changes
|
||||
const dateInTz = date.add(fromOffset - offset, "minutes").tz(timeZone);
|
||||
const dateInTz = date.add(fromOffset - offset, "minutes").tz(adjustedTimezone);
|
||||
if (!item.days.includes(dateInTz.day())) {
|
||||
continue;
|
||||
}
|
||||
@@ -40,14 +62,14 @@ export function processWorkingHours({
|
||||
|
||||
let end = dateInTz.add(item.endTime.getUTCHours(), "hours").add(item.endTime.getUTCMinutes(), "minutes");
|
||||
|
||||
const offsetBeginningOfDay = dayjs(start.format("YYYY-MM-DD hh:mm")).tz(timeZone).utcOffset();
|
||||
const offsetBeginningOfDay = dayjs(start.format("YYYY-MM-DD hh:mm")).tz(adjustedTimezone).utcOffset();
|
||||
const offsetDiff = start.utcOffset() - offsetBeginningOfDay; // there will be 60 min offset on the day day of DST change
|
||||
|
||||
start = start.add(offsetDiff, "minute");
|
||||
end = end.add(offsetDiff, "minute");
|
||||
|
||||
const startResult = dayjs.max(start, dateFrom);
|
||||
let endResult = dayjs.min(end, dateTo.tz(timeZone));
|
||||
let endResult = dayjs.min(end, dateTo.tz(adjustedTimezone));
|
||||
|
||||
// INFO: We only allow users to set availability up to 11:59PM which ends up not making them available
|
||||
// up to midnight.
|
||||
@@ -72,17 +94,23 @@ export function processDateOverride({
|
||||
item,
|
||||
itemDateAsUtc,
|
||||
timeZone,
|
||||
travelSchedules,
|
||||
}: {
|
||||
item: DateOverride;
|
||||
itemDateAsUtc: Dayjs;
|
||||
timeZone: string;
|
||||
travelSchedules: TravelSchedule[];
|
||||
}) {
|
||||
const overrideDate = dayjs(item.date);
|
||||
|
||||
const adjustedTimezone = getAdjustedTimezone(overrideDate, timeZone, travelSchedules);
|
||||
|
||||
const itemDateStartOfDay = itemDateAsUtc.startOf("day");
|
||||
const startDate = itemDateStartOfDay
|
||||
.add(item.startTime.getUTCHours(), "hours")
|
||||
.add(item.startTime.getUTCMinutes(), "minutes")
|
||||
.second(0)
|
||||
.tz(timeZone, true);
|
||||
.tz(adjustedTimezone, true);
|
||||
|
||||
let endDate = itemDateStartOfDay;
|
||||
const endTimeHours = item.endTime.getUTCHours();
|
||||
@@ -95,7 +123,7 @@ export function processDateOverride({
|
||||
.add(endTimeHours, "hours")
|
||||
.add(endTimeMinutes, "minutes")
|
||||
.second(0)
|
||||
.tz(timeZone, true);
|
||||
.tz(adjustedTimezone, true);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -121,12 +149,14 @@ export function buildDateRanges({
|
||||
timeZone /* Organizer timeZone */,
|
||||
dateFrom /* Attendee dateFrom */,
|
||||
dateTo /* `` dateTo */,
|
||||
travelSchedules,
|
||||
outOfOffice,
|
||||
}: {
|
||||
timeZone: string;
|
||||
availability: (DateOverride | WorkingHours)[];
|
||||
dateFrom: Dayjs;
|
||||
dateTo: Dayjs;
|
||||
travelSchedules: TravelSchedule[];
|
||||
outOfOffice?: IOutOfOfficeData;
|
||||
}): { dateRanges: DateRange[]; oooExcludedDateRanges: DateRange[] } {
|
||||
const dateFromOrganizerTZ = dateFrom.tz(timeZone);
|
||||
@@ -134,7 +164,7 @@ export function buildDateRanges({
|
||||
availability.reduce((processed: DateRange[], item) => {
|
||||
if ("days" in item) {
|
||||
processed = processed.concat(
|
||||
processWorkingHours({ item, timeZone, dateFrom: dateFromOrganizerTZ, dateTo })
|
||||
processWorkingHours({ item, timeZone, dateFrom: dateFromOrganizerTZ, dateTo, travelSchedules })
|
||||
);
|
||||
}
|
||||
return processed;
|
||||
@@ -163,7 +193,7 @@ export function buildDateRanges({
|
||||
"[]"
|
||||
)
|
||||
) {
|
||||
processed.push(processDateOverride({ item, itemDateAsUtc, timeZone }));
|
||||
processed.push(processDateOverride({ item, itemDateAsUtc, timeZone, travelSchedules }));
|
||||
}
|
||||
}
|
||||
return processed;
|
||||
|
||||
@@ -49,6 +49,7 @@ const user: User & { credentials: CredentialPayload[] } = {
|
||||
darkBrandColor: "#efefef",
|
||||
allowDynamicBooking: true,
|
||||
timeFormat: 12,
|
||||
travelSchedules: [],
|
||||
};
|
||||
|
||||
const customInputs: CustomInputSchema[] = [];
|
||||
|
||||
@@ -10,6 +10,7 @@ import WebShell from "@calcom/features/shell/Shell";
|
||||
import { availabilityAsString } from "@calcom/lib/availability";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import type { TimeRange, WorkingHours } from "@calcom/types/schedule";
|
||||
import {
|
||||
Button,
|
||||
@@ -74,6 +75,7 @@ type AvailabilitySettingsProps = {
|
||||
timeZone: string;
|
||||
schedule: Schedule[];
|
||||
};
|
||||
travelSchedules?: RouterOutputs["viewer"]["getTravelSchedules"];
|
||||
handleDelete: () => void;
|
||||
isDeleting: boolean;
|
||||
isSaving: boolean;
|
||||
@@ -143,9 +145,11 @@ const useExcludedDates = () => {
|
||||
const DateOverride = ({
|
||||
workingHours,
|
||||
userTimeFormat,
|
||||
travelSchedules,
|
||||
}: {
|
||||
workingHours: WorkingHours[];
|
||||
userTimeFormat: number | null;
|
||||
travelSchedules?: RouterOutputs["viewer"]["getTravelSchedules"];
|
||||
}) => {
|
||||
const { append, replace, fields } = useFieldArray<AvailabilityFormValues, "dateOverrides">({
|
||||
name: "dateOverrides",
|
||||
@@ -171,6 +175,7 @@ const DateOverride = ({
|
||||
workingHours={workingHours}
|
||||
userTimeFormat={userTimeFormat}
|
||||
hour12={Boolean(userTimeFormat === 12)}
|
||||
travelSchedules={travelSchedules}
|
||||
/>
|
||||
<DateOverrideInputDialog
|
||||
workingHours={workingHours}
|
||||
@@ -213,6 +218,7 @@ const SmallScreenSideBar = ({ open, children }: { open: boolean; children: JSX.E
|
||||
|
||||
export function AvailabilitySettings({
|
||||
schedule,
|
||||
travelSchedules,
|
||||
handleDelete,
|
||||
isDeleting,
|
||||
isLoading,
|
||||
@@ -483,8 +489,12 @@ export function AvailabilitySettings({
|
||||
</div>
|
||||
{!isPlatform ? (
|
||||
<div className="border-subtle my-6 rounded-md border">
|
||||
{schedule.workingHours && (
|
||||
<DateOverride workingHours={schedule.workingHours} userTimeFormat={timeFormat} />
|
||||
{schedule?.workingHours && (
|
||||
<DateOverride
|
||||
workingHours={schedule.workingHours}
|
||||
userTimeFormat={timeFormat}
|
||||
travelSchedules={travelSchedules}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -25,6 +25,12 @@ export const WebAvailabilitySettingsWrapper = () => {
|
||||
enabled: !!scheduleId,
|
||||
}
|
||||
);
|
||||
|
||||
const { data: travelSchedules, isPending: isPendingTravelSchedules } =
|
||||
trpc.viewer.getTravelSchedules.useQuery();
|
||||
|
||||
const isDefaultSchedule = me.data?.defaultScheduleId === scheduleId;
|
||||
|
||||
const updateMutation = trpc.viewer.availability.schedule.update.useMutation({
|
||||
onSuccess: async ({ prevDefaultId, currentDefaultId, ...data }) => {
|
||||
if (prevDefaultId && currentDefaultId) {
|
||||
@@ -76,6 +82,7 @@ export const WebAvailabilitySettingsWrapper = () => {
|
||||
return (
|
||||
<AvailabilitySettings
|
||||
schedule={schedule}
|
||||
travelSchedules={isDefaultSchedule ? travelSchedules || [] : []}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
isLoading={isPending}
|
||||
isSaving={updateMutation.isPending}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "TravelSchedule" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"timeZone" TEXT NOT NULL,
|
||||
"startDate" TIMESTAMP(3) NOT NULL,
|
||||
"endDate" TIMESTAMP(3),
|
||||
"prevTimeZone" TEXT,
|
||||
|
||||
CONSTRAINT "TravelSchedule_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TravelSchedule_startDate_idx" ON "TravelSchedule"("startDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TravelSchedule_endDate_idx" ON "TravelSchedule"("endDate");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TravelSchedule" ADD CONSTRAINT "TravelSchedule_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -212,6 +212,19 @@ model UserPassword {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model TravelSchedule {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
timeZone String
|
||||
startDate DateTime
|
||||
endDate DateTime?
|
||||
prevTimeZone String?
|
||||
|
||||
@@index([startDate])
|
||||
@@index([endDate])
|
||||
}
|
||||
|
||||
// It holds Personal Profiles of a User plus it has email, password and other core things
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
@@ -225,6 +238,7 @@ model User {
|
||||
avatar String?
|
||||
avatarUrl String?
|
||||
timeZone String @default("Europe/London")
|
||||
travelSchedules TravelSchedule[]
|
||||
weekStart String @default("Sunday")
|
||||
// DEPRECATED - TO BE REMOVED
|
||||
startTime Int @default(0)
|
||||
|
||||
@@ -27,6 +27,7 @@ export const availabilityUserSelect = Prisma.validator<Prisma.UserSelect>()({
|
||||
},
|
||||
availability: true,
|
||||
selectedCalendars: true,
|
||||
travelSchedules: true,
|
||||
});
|
||||
|
||||
export const baseUserSelect = Prisma.validator<Prisma.UserSelect>()({
|
||||
|
||||
@@ -55,6 +55,7 @@ type AppsRouterHandlerCache = {
|
||||
outOfOfficeEntriesList?: typeof import("./outOfOffice.handler").outOfOfficeEntriesList;
|
||||
outOfOfficeEntryDelete?: typeof import("./outOfOffice.handler").outOfOfficeEntryDelete;
|
||||
addSecondaryEmail?: typeof import("./addSecondaryEmail.handler").addSecondaryEmailHandler;
|
||||
getTravelSchedules?: typeof import("./getTravelSchedules.handler").getTravelSchedulesHandler;
|
||||
outOfOfficeReasonList?: typeof import("./outOfOfficeReasons.handler").outOfOfficeReasonList;
|
||||
};
|
||||
|
||||
@@ -485,6 +486,20 @@ export const loggedInViewerRouter = router({
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.addSecondaryEmail({ ctx, input });
|
||||
}),
|
||||
getTravelSchedules: authedProcedure.query(async ({ ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.getTravelSchedules) {
|
||||
UNSTABLE_HANDLER_CACHE.getTravelSchedules = (
|
||||
await import("./getTravelSchedules.handler")
|
||||
).getTravelSchedulesHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.getTravelSchedules) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.getTravelSchedules({ ctx });
|
||||
}),
|
||||
outOfOfficeReasonList: authedProcedure.query(async () => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeReasonList) {
|
||||
UNSTABLE_HANDLER_CACHE.outOfOfficeReasonList = (
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
type GetTravelSchedulesOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
};
|
||||
|
||||
export const getTravelSchedulesHandler = async ({ ctx }: GetTravelSchedulesOptions) => {
|
||||
const allTravelSchedules = await prisma.travelSchedule.findMany({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
timeZone: true,
|
||||
},
|
||||
});
|
||||
|
||||
return allTravelSchedules;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -69,6 +69,8 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
|
||||
const locale = input.locale || user.locale;
|
||||
const emailVerification = await getFeatureFlag(prisma, "email-verification");
|
||||
|
||||
const { travelSchedules, ...rest } = input;
|
||||
|
||||
const secondaryEmails = input?.secondaryEmails || [];
|
||||
delete input.secondaryEmails;
|
||||
|
||||
@@ -76,7 +78,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
|
||||
delete input.unlinkConnectedAccount;
|
||||
|
||||
const data: Prisma.UserUpdateInput = {
|
||||
...input,
|
||||
...rest,
|
||||
// DO NOT OVERWRITE AVATAR.
|
||||
avatar: undefined,
|
||||
metadata: userMetadata,
|
||||
@@ -234,6 +236,41 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
|
||||
}
|
||||
}
|
||||
|
||||
if (travelSchedules) {
|
||||
const existingSchedules = await prisma.travelSchedule.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const schedulesToDelete = existingSchedules.filter(
|
||||
(schedule) =>
|
||||
!travelSchedules || !travelSchedules.find((scheduleInput) => scheduleInput.id === schedule.id)
|
||||
);
|
||||
|
||||
await prisma.travelSchedule.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
id: {
|
||||
in: schedulesToDelete.map((schedule) => schedule.id) as number[],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.travelSchedule.createMany({
|
||||
data: travelSchedules
|
||||
.filter((schedule) => !schedule.id)
|
||||
.map((schedule) => {
|
||||
return {
|
||||
userId: user.id,
|
||||
startDate: schedule.startDate,
|
||||
endDate: schedule.endDate,
|
||||
timeZone: schedule.timeZone,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const updatedUserSelect = Prisma.validator<Prisma.UserDefaultArgs>()({
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -29,6 +29,16 @@ export const ZUpdateProfileInputSchema = z.object({
|
||||
timeFormat: z.number().optional(),
|
||||
disableImpersonation: z.boolean().optional(),
|
||||
metadata: userMetadata.optional(),
|
||||
travelSchedules: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
timeZone: z.string(),
|
||||
endDate: z.date().optional(),
|
||||
startDate: z.date(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
secondaryEmails: z
|
||||
.array(
|
||||
z.object({
|
||||
|
||||
@@ -48,6 +48,7 @@ async function getTeamMembers({
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
travelSchedules: true,
|
||||
timeZone: true,
|
||||
defaultScheduleId: true,
|
||||
},
|
||||
@@ -102,6 +103,13 @@ async function buildMember(member: Member, dateFrom: Dayjs, dateTo: Dayjs) {
|
||||
dateTo,
|
||||
timeZone,
|
||||
availability: schedule?.availability ?? [],
|
||||
travelSchedules: member.user.travelSchedules.map((schedule) => {
|
||||
return {
|
||||
startDate: dayjs(schedule.startDate),
|
||||
endDate: schedule.endDate ? dayjs(schedule.endDate) : undefined,
|
||||
timeZone: schedule.timeZone,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -169,6 +169,7 @@ export async function getEventType(
|
||||
metadata: true,
|
||||
schedule: {
|
||||
select: {
|
||||
id: true,
|
||||
availability: {
|
||||
select: {
|
||||
date: true,
|
||||
|
||||
Vendored
+9
@@ -18,3 +18,12 @@ export type WorkingHours = {
|
||||
endTime: number;
|
||||
userId?: number | null;
|
||||
};
|
||||
|
||||
export type TravelSchedule = {
|
||||
id: number;
|
||||
timeZone: string;
|
||||
userId: number;
|
||||
startDate: Date;
|
||||
endDate: Date | null;
|
||||
prevTimeZone: string | null;
|
||||
};
|
||||
@@ -21,11 +21,10 @@ const DatePicker = ({ minDate, disabled, date, onDatesChange, className }: Props
|
||||
"focus:ring-primary-500 focus:border-primary-500 border-default rounded-md border p-1 pl-2 shadow-sm sm:text-sm",
|
||||
className
|
||||
)}
|
||||
calendarClassName="rounded-md dark:text-black"
|
||||
calendarClassName="rounded-md"
|
||||
clearIcon={null}
|
||||
calendarIcon={<Icon name="calendar" className="text-subtle h-5 w-5 rounded-md" />}
|
||||
value={date}
|
||||
minDate={minDate}
|
||||
disabled={disabled}
|
||||
onChange={onDatesChange}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user