forked from calcom/cal.com
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: travel schedules to schedule timezone changes (calcom#13951)
* 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 --------- Co-authored-by: CarinaWolli <[email protected]> Co-authored-by: Alex van Andel <[email protected]> Co-authored-by: Joe Au-Yeung <[email protected]>
- Loading branch information
1 parent
9896875
commit 406a573
Showing
27 changed files
with
844 additions
and
35 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 }); | ||
} |
Oops, something went wrong.