Skip to content

Commit

Permalink
feat: travel schedules to schedule timezone changes (#14512)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
4 people committed Apr 12, 2024
1 parent 5561949 commit 19d938e
Show file tree
Hide file tree
Showing 26 changed files with 873 additions and 33 deletions.
24 changes: 24 additions & 0 deletions .github/workflows/cron-changeTimeZone.yml
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
160 changes: 160 additions & 0 deletions apps/web/components/settings/TravelScheduleModal.tsx
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;
9 changes: 9 additions & 0 deletions apps/web/lib/types/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 173 additions & 0 deletions apps/web/pages/api/cron/changeTimeZone.ts
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 });
}

0 comments on commit 19d938e

Please sign in to comment.