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:
authored and GitHub committed 2024-04-12 17:57:28 +00:00
1 parent 5561949f9b
commit 19d938ed60
26 files changed
+873 -33

No files matched your search

+24
View File
@@ -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;
+9
View File
@@ -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;
};
+173
View File
@@ -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 });
}
+116 -6
View File
@@ -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",
+12
View File
@@ -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>
))
)}
+157 -12
View File
@@ -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,
});
+38 -8
View File
@@ -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;
+1
View File
@@ -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;
+14
View File
@@ -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)
+1
View File
@@ -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,
+9
View File
@@ -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}
/>