diff --git a/.github/workflows/cron-changeTimeZone.yml b/.github/workflows/cron-changeTimeZone.yml new file mode 100644 index 0000000000..5a988dc9f4 --- /dev/null +++ b/.github/workflows/cron-changeTimeZone.yml @@ -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 diff --git a/apps/web/components/settings/TravelScheduleModal.tsx b/apps/web/components/settings/TravelScheduleModal.tsx new file mode 100644 index 0000000000..40e8909f08 --- /dev/null +++ b/apps/web/components/settings/TravelScheduleModal.tsx @@ -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; + existingSchedules: FormValues["travelSchedules"]; +} + +const TravelScheduleModal = ({ + open, + onOpenChange, + setValue, + existingSchedules, +}: TravelScheduleModalProps) => { + const { t } = useLocale(); + const { timezone: preferredTimezone } = useTimePreferences(); + + const [startDate, setStartDate] = useState(new Date()); + const [endDate, setEndDate] = useState(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 ( + + +
+ {!isNoEndDate ? ( + <> + + { + setStartDate(newStartDate); + setEndDate(newEndDate); + setErrorMessage(""); + }} + /> + + ) : ( + <> + + { + setStartDate(newDate); + setErrorMessage(""); + }} + /> + + )} +
{errorMessage}
+
+ { + setEndDate(!e ? startDate : undefined); + setIsNoEndDate(e); + setErrorMessage(""); + }} + /> +
+ + setSelectedTimeZone(value)} + className="mb-11 mt-2 w-full rounded-md text-sm" + /> +
+ + + + +
+
+ ); +}; + +export default TravelScheduleModal; diff --git a/apps/web/lib/types/schedule.ts b/apps/web/lib/types/schedule.ts index ba5e74b45e..5b724da33f 100644 --- a/apps/web/lib/types/schedule.ts +++ b/apps/web/lib/types/schedule.ts @@ -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; +}; diff --git a/apps/web/pages/api/cron/changeTimeZone.ts b/apps/web/pages/api/cron/changeTimeZone.ts new file mode 100644 index 0000000000..43cbbc4a0b --- /dev/null +++ b/apps/web/pages/api/cron/changeTimeZone.ts @@ -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 }); +} diff --git a/apps/web/pages/settings/my-account/general.tsx b/apps/web/pages/settings/my-account/general.tsx index 4802e612d3..9bb18622a9 100644 --- a/apps/web/pages/settings/my-account/general.tsx +++ b/apps/web/pages/settings/my-account/general.tsx @@ -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 ; + + const { data: travelSchedules, isPending: isPendingTravelSchedules } = + trpc.viewer.getTravelSchedules.useQuery(); + + if (isPending || isPendingTravelSchedules) + return ; if (!user) { throw new Error(t("something_went_wrong")); } - return ; + return ; }; -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(false); + const [isTZScheduleOpen, setIsTZScheduleOpen] = useState(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({ 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 (
{ )} /> + {!watchedTzSchedules.length ? ( + + ) : ( +
+ +
+ {watchedTzSchedules.map((schedule, index) => { + return ( +
+
+
{`${formatLocalizedDateTime( + schedule.startDate, + { day: "numeric", month: "long" }, + language + )} ${ + schedule.endDate + ? `- ${formatLocalizedDateTime( + schedule.endDate, + { day: "numeric", month: "long" }, + language + )}` + : `` + }`}
+
{schedule.timeZone.replace(/_/g, " ")}
+
+
+ ); + })} +
+ +
+ )} + { }} switchContainerClassName="mt-6" /> + setIsTZScheduleOpen(false)} + setValue={formMethods.setValue} + existingSchedules={formMethods.getValues("travelSchedules") ?? []} + />
); }; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index fa2278ffcd..25280e65cd 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 {{email}}. 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", diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 0df941aaec..174242dec8 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -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, }); diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 22d40c6072..a87834910e 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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 || []; diff --git a/packages/features/schedules/components/DateOverrideList.tsx b/packages/features/schedules/components/DateOverrideList.tsx index ea96959be5..de1c569a1b 100644 --- a/packages/features/schedules/components/DateOverrideList.tsx +++ b/packages/features/schedules/components/DateOverrideList.tsx @@ -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) => (

- {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, " ") || "" + }`} + <>

)) )} diff --git a/packages/lib/date-ranges.test.ts b/packages/lib/date-ranges.test.ts index 722ac4ed20..9e766b18d7 100644 --- a/packages/lib/date-ranges.test.ts +++ b/packages/lib/date-ranges.test.ts @@ -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, }); diff --git a/packages/lib/date-ranges.ts b/packages/lib/date-ranges.ts index 0722861247..19e644009f 100644 --- a/packages/lib/date-ranges.ts +++ b/packages/lib/date-ranges.ts @@ -11,25 +11,47 @@ export type DateRange = { export type DateOverride = Pick; export type WorkingHours = Pick; +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; diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index 41165efa70..f3fffa7060 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -49,6 +49,7 @@ const user: User & { credentials: CredentialPayload[] } = { darkBrandColor: "#efefef", allowDynamicBooking: true, timeFormat: 12, + travelSchedules: [], }; const customInputs: CustomInputSchema[] = []; diff --git a/packages/platform/atoms/availability/AvailabilitySettings.tsx b/packages/platform/atoms/availability/AvailabilitySettings.tsx index ceba69d268..1cacbd026e 100644 --- a/packages/platform/atoms/availability/AvailabilitySettings.tsx +++ b/packages/platform/atoms/availability/AvailabilitySettings.tsx @@ -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({ name: "dateOverrides", @@ -171,6 +175,7 @@ const DateOverride = ({ workingHours={workingHours} userTimeFormat={userTimeFormat} hour12={Boolean(userTimeFormat === 12)} + travelSchedules={travelSchedules} /> {!isPlatform ? (
- {schedule.workingHours && ( - + {schedule?.workingHours && ( + )}
) : ( diff --git a/packages/platform/atoms/availability/wrappers/WebAvailabilitySettingsWrapper.tsx b/packages/platform/atoms/availability/wrappers/WebAvailabilitySettingsWrapper.tsx index a3702d7208..8cc158539b 100644 --- a/packages/platform/atoms/availability/wrappers/WebAvailabilitySettingsWrapper.tsx +++ b/packages/platform/atoms/availability/wrappers/WebAvailabilitySettingsWrapper.tsx @@ -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 ( ()({ }, availability: true, selectedCalendars: true, + travelSchedules: true, }); export const baseUserSelect = Prisma.validator()({ diff --git a/packages/trpc/server/routers/loggedInViewer/_router.tsx b/packages/trpc/server/routers/loggedInViewer/_router.tsx index a71b7decec..aea84cca8a 100644 --- a/packages/trpc/server/routers/loggedInViewer/_router.tsx +++ b/packages/trpc/server/routers/loggedInViewer/_router.tsx @@ -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 = ( diff --git a/packages/trpc/server/routers/loggedInViewer/getTravelSchedules.handler.ts b/packages/trpc/server/routers/loggedInViewer/getTravelSchedules.handler.ts new file mode 100644 index 0000000000..a47e454ae5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/getTravelSchedules.handler.ts @@ -0,0 +1,24 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type GetTravelSchedulesOptions = { + ctx: { + user: NonNullable; + }; +}; + +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; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/getTravelSchedules.schema.ts b/packages/trpc/server/routers/loggedInViewer/getTravelSchedules.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/getTravelSchedules.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts index d06ff775bb..1367b34951 100644 --- a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts @@ -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()({ select: { id: true, diff --git a/packages/trpc/server/routers/loggedInViewer/updateProfile.schema.ts b/packages/trpc/server/routers/loggedInViewer/updateProfile.schema.ts index c621f69856..b63b311c29 100644 --- a/packages/trpc/server/routers/loggedInViewer/updateProfile.schema.ts +++ b/packages/trpc/server/routers/loggedInViewer/updateProfile.schema.ts @@ -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({ diff --git a/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts b/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts index 6d81942136..0abf185b4b 100644 --- a/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts +++ b/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts @@ -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 { diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 71168fd5fc..ecb7b54729 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -169,6 +169,7 @@ export async function getEventType( metadata: true, schedule: { select: { + id: true, availability: { select: { date: true, diff --git a/packages/types/schedule.d.ts b/packages/types/schedule.d.ts index 7b36261da4..1b441e4a8d 100644 --- a/packages/types/schedule.d.ts +++ b/packages/types/schedule.d.ts @@ -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; +}; diff --git a/packages/ui/components/form/datepicker/DatePicker.tsx b/packages/ui/components/form/datepicker/DatePicker.tsx index 8994c31b7a..f503af72aa 100644 --- a/packages/ui/components/form/datepicker/DatePicker.tsx +++ b/packages/ui/components/form/datepicker/DatePicker.tsx @@ -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={} value={date} - minDate={minDate} disabled={disabled} onChange={onDatesChange} />