Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: travel schedules to schedule timezone changes #14512

Merged
merged 68 commits into from
Apr 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
f5d6fc5
add Travel Schedule modal
Feb 9, 2024
91d9c49
UI to list schedules
Feb 9, 2024
5616692
Merge branch 'main' into feat/schedule-timezone-change
Feb 13, 2024
c00914d
set shouldDirty
Feb 13, 2024
b53cf71
add backend to add new travelSchedule
Feb 13, 2024
15da670
implement deleting a schedule
Feb 13, 2024
b938f76
Merge branch 'main' into feat/schedule-timezone-change
Feb 15, 2024
3fb8a41
check if schedule is overlapping
Feb 15, 2024
58c590a
WIP
Feb 15, 2024
48d8aeb
Merge branch 'main' into feat/schedule-timezone-change
Feb 16, 2024
6d46d58
fix finding overlapping travel schedule
Feb 16, 2024
5d4c0bb
only use travelSchedule when default availability is used
Feb 16, 2024
8a5bf93
Merge branch 'main' into feat/schedule-timezone-change
Feb 19, 2024
e0f68c1
adjust date overrides to timezone schedule
Feb 19, 2024
eb822cf
first version of changeTimeZone cron job
Feb 19, 2024
09e7900
Merge branch 'main' into feat/schedule-timezone-change
Feb 20, 2024
498b1ba
fix tests by adding travelSchedules
Feb 20, 2024
9d973fd
fixes for cron job api call
Feb 22, 2024
bf38186
improve unit tests
Mar 1, 2024
60ba1f8
Merge branch 'main' into feat/schedule-timezone-change
Mar 1, 2024
768f895
add migration
Mar 1, 2024
a2c2e05
fix type error
Mar 1, 2024
b9f472d
fix collective-scheduling test
Mar 1, 2024
50bb5c8
clean up cron job
Mar 1, 2024
8d917a0
code clean up
Mar 1, 2024
9ef35e8
code clean up
Mar 1, 2024
9c68ed2
show timezone from travel schedule for date override
Mar 1, 2024
b999588
Merge branch 'main' into feat/schedule-timezone-change
Mar 4, 2024
cc0bd24
add date override tests
Mar 4, 2024
feeab9e
show tz on date override only in default schedule
Mar 4, 2024
e5fa96f
fix deleting old schedules
Mar 4, 2024
3de09ba
minor fixes in cron api handler
Mar 4, 2024
fb13ebf
Merge branch 'main' into feat/schedule-timezone-change
CarinaWolli Mar 4, 2024
173fb86
code clean up
Mar 5, 2024
1d12c47
code clean up from feedback
Mar 6, 2024
d5fc82c
fix asia/kalkota comment
Mar 6, 2024
768cffd
fix dark mode
Mar 6, 2024
ea5d9d6
Merge branch 'main' into feat/schedule-timezone-change
Mar 6, 2024
7835e4e
fix start and end date conversion to utc
Mar 7, 2024
cc044c2
add first unit test for travel schedules
Feb 28, 2024
0e4e104
Merge branch 'main' into feat/schedule-timezone-change
Mar 12, 2024
4199068
Fix modal render issue
emrysal Mar 12, 2024
93466cb
Merge branch 'main' into feat/schedule-timezone-change
Mar 14, 2024
29a747b
Merge branch 'main' into feat/schedule-timezone-change
joeauyeung Mar 14, 2024
7437501
show timezone city wtihout _
Mar 19, 2024
f0bc46d
fix dark more for datepicker
Mar 19, 2024
0c40cf0
reset values after closing dialog
Mar 19, 2024
93f42ae
remove session from middleware
Mar 19, 2024
e8a10ea
exit loop early once schedule is found
Mar 19, 2024
2ba0280
fix type error
Mar 19, 2024
bf1bffd
Merge branch 'main' into feat/schedule-timezone-change
CarinaWolli Mar 19, 2024
3612a40
add getTravelSchedules handler
Mar 19, 2024
8a0da41
clean up DatePicker
Mar 19, 2024
400bf2f
Merge branch 'main' into feat/schedule-timezone-change
Mar 20, 2024
81562f0
fix type error
Mar 20, 2024
6accb84
Merge branch 'main' into feat/schedule-timezone-change
Mar 21, 2024
9ddce51
code clean up
Mar 21, 2024
5ca1380
code clean up
Mar 21, 2024
1233526
Merge branch 'main' into feat/schedule-timezone-change
joeauyeung Mar 21, 2024
fd70b67
add indexes
Mar 21, 2024
6f09c4c
use deleteMany
Mar 21, 2024
39d0ffe
Merge branch 'main' into feat/schedule-timezone-change
Mar 22, 2024
7483972
Merge branch 'main' into fix/schedule-timezone-change-new
Apr 10, 2024
65cdcf0
fix icons
Apr 10, 2024
6d5bab8
Merge branch 'main' into fix/schedule-timezone-change-new
sean-brydon Apr 11, 2024
4550f8d
fix unit test
Apr 11, 2024
4d4c2e4
Merge branch 'main' into fix/schedule-timezone-change-new
CarinaWolli Apr 12, 2024
bb55709
Merge branch 'main' into fix/schedule-timezone-change-new
zomars Apr 12, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 });
}