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

Fix/DST issues #7462

Merged
merged 13 commits into from
Mar 3, 2023
4 changes: 2 additions & 2 deletions apps/web/components/booking/pages/BookingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -426,8 +426,8 @@ const BookingPage = ({
} else {
mutation.mutate({
...booking,
start: dayjs(date).format(),
end: dayjs(date).add(duration, "minute").format(),
start: dayjs(date).tz(timeZone()).format(),
end: dayjs(date).tz(timeZone()).add(duration, "minute").format(),
eventTypeId: eventType.id,
eventTypeSlug: eventType.slug,
timeZone: timeZone(),
Expand Down
5 changes: 5 additions & 0 deletions apps/web/test/lib/slots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe("Tests the slot logic", () => {
},
],
eventLength: 60,
organizerTimeZone: "America/Toronto",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does Toronto has a special DST/TZ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, nothing special

})
).toHaveLength(24);
});
Expand All @@ -45,6 +46,7 @@ describe("Tests the slot logic", () => {
},
],
eventLength: 60,
organizerTimeZone: "America/Toronto",
})
).toHaveLength(12);
});
Expand All @@ -64,6 +66,7 @@ describe("Tests the slot logic", () => {
},
],
eventLength: 60,
organizerTimeZone: "America/Toronto",
})
).toHaveLength(0);
});
Expand All @@ -84,6 +87,7 @@ describe("Tests the slot logic", () => {
minimumBookingNotice: 0,
workingHours,
eventLength: 60,
organizerTimeZone: "America/Toronto",
})
).toHaveLength(0);
});
Expand All @@ -104,6 +108,7 @@ describe("Tests the slot logic", () => {
},
],
eventLength: 60,
organizerTimeZone: "America/Toronto",
})
).toHaveLength(11);
});
Expand Down
38 changes: 33 additions & 5 deletions packages/features/bookings/lib/handleNewBooking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { cancelScheduledJobs, scheduleTrigger } from "@calcom/app-store/zapier/l
import EventManager from "@calcom/core/EventManager";
import { getEventName } from "@calcom/core/event";
import { getUserAvailability } from "@calcom/core/getUserAvailability";
import type { ConfigType } from "@calcom/dayjs";
import type { ConfigType, Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import {
sendAttendeeRequestEmail,
Expand All @@ -35,6 +35,7 @@ import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/re
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { getVideoCallUrl } from "@calcom/lib/CalEventParser";
import { getDSTDifference, isInDST } from "@calcom/lib/date-fns";
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
Expand Down Expand Up @@ -111,16 +112,40 @@ const isWithinAvailableHours = (
timeSlot: { start: ConfigType; end: ConfigType },
{
workingHours,
organizerTimeZone,
inviteeTimeZone,
}: {
workingHours: WorkingHours[];
organizerTimeZone: string;
inviteeTimeZone: string;
}
) => {
const timeSlotStart = dayjs(timeSlot.start).utc();
const timeSlotEnd = dayjs(timeSlot.end).utc();
const isOrganizerInDST = isInDST(dayjs().tz(organizerTimeZone));
const isInviteeInDST = isInDST(dayjs().tz(organizerTimeZone));
const isOrganizerInDSTWhenSlotStart = isInDST(timeSlotStart.tz(organizerTimeZone));
const isInviteeInDSTWhenSlotStart = isInDST(timeSlotStart.tz(inviteeTimeZone));
const organizerDSTDifference = getDSTDifference(organizerTimeZone);
const inviteeDSTDifference = getDSTDifference(inviteeTimeZone);
const sameDSTUsers = isOrganizerInDSTWhenSlotStart === isInviteeInDSTWhenSlotStart;
const organizerDST = isOrganizerInDST === isOrganizerInDSTWhenSlotStart;
const inviteeDST = isInviteeInDST === isInviteeInDSTWheSlotStart;
roae marked this conversation as resolved.
Show resolved Hide resolved
const getTime = (slotTime: Dayjs, minutes: number) =>
slotTime
.startOf("day")
.add(
sameDSTUsers && organizerDST && inviteeDST
? minutes
: minutes -
(isOrganizerInDSTWhenSlotStart || isOrganizerInDST
? organizerDSTDifference
: -inviteeDSTDifference),
roae marked this conversation as resolved.
Show resolved Hide resolved
"minutes"
);
for (const workingHour of workingHours) {
// TODO: Double check & possibly fix timezone conversions.
const startTime = timeSlotStart.startOf("day").add(workingHour.startTime, "minute");
const endTime = timeSlotEnd.startOf("day").add(workingHour.endTime, "minute");
const startTime = getTime(timeSlotStart, workingHour.startTime);
const endTime = getTime(timeSlotEnd, workingHour.endTime);
if (
workingHour.days.includes(timeSlotStart.day()) &&
// UTC mode, should be performant.
Expand Down Expand Up @@ -247,7 +272,7 @@ async function ensureAvailableUsers(
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>> & {
users: IsFixedAwareUser[];
},
input: { dateFrom: string; dateTo: string },
input: { dateFrom: string; dateTo: string; timeZone: string },
recurringDatesInfo?: {
allRecurringDates: string[] | undefined;
currentRecurringIndex: number | undefined;
Expand All @@ -271,6 +296,8 @@ async function ensureAvailableUsers(
{ start: input.dateFrom, end: input.dateTo },
{
workingHours,
organizerTimeZone: eventType.timeZone || eventType?.schedule?.timeZone || user.timeZone,
inviteeTimeZone: input.timeZone,
}
)
) {
Expand Down Expand Up @@ -567,6 +594,7 @@ async function handler(
{
dateFrom: reqBody.start,
dateTo: reqBody.end,
timeZone: reqBody.timeZone,
},
{
allRecurringDates,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import classNames from "classnames";
import React from "react";
import { ITimezone } from "react-timezone-select";
import type { ITimezone } from "react-timezone-select";

import { Dayjs } from "@calcom/dayjs";
import type { Dayjs } from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import getSlots from "@calcom/lib/slots";
import { trpc } from "@calcom/trpc/react";
Expand Down Expand Up @@ -42,6 +42,7 @@ export default function TeamAvailabilityTimes(props: Props) {
workingHours: data?.workingHours || [],
minimumBookingNotice: 0,
eventLength: props.frequency,
organizerTimeZone: `${data?.timeZone}`,
})
: [];

Expand Down
66 changes: 66 additions & 0 deletions packages/lib/date-fns/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,69 @@ export const isNextDayInTimezone = (time: string, timezoneA: string, timezoneB:
const timezoneBIsLaterTimezone = sortByTimezone(timezoneA, timezoneB) === -1;
return hoursTimezoneBIsEarlier && timezoneBIsLaterTimezone;
};

/**
* Dayjs does not expose the timeZone value publicly through .get("timeZone")
* instead, we as devs are required to somewhat hack our way to get the
* tz value as string
* @param date Dayjs
* @returns Time Zone name
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const getTimeZone = (date: Dayjs): string => (date as any)["$x"]["$timezone"];

/**
* Verify if timeZone has Daylight Saving Time (DST).
*
* Many countries in the Northern Hemisphere. Daylight Saving Time usually starts in March-April and ends in
* September-November when the countries return to standard time, or winter time as it is also known.
*
* In the Southern Hemisphere (south of the equator) the participating countries usually start the DST period
* in September-November and end DST in March-April.
*
* @param timeZone Time Zone Name (Ex. America/Mazatlan)
* @returns boolean
*/
export const timeZoneWithDST = (timeZone: string): boolean => {
const jan = dayjs.tz(`${new Date().getFullYear()}-01-01T00:00:00`, timeZone);
const jul = dayjs.tz(`${new Date().getFullYear()}-07-01T00:00:00`, timeZone);
return jan.utcOffset() !== jul.utcOffset();
};

/**
* Get DST difference.
* Today clocks are almost always set one hour back or ahead.
* However, on Lord Howe Island, Australia, clocks are set only 30 minutes forward
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🥲

* from LHST (UTC+10:30) to LHDT (UTC+11) during DST.
* @param timeZone Time Zone Name (Ex. America/Mazatlan)
* @returns minutes
*/
export const getDSTDifference = (timeZone: string): number => {
const jan = dayjs.tz(`${new Date().getFullYear()}-01-01T00:00:00`, timeZone);
const jul = dayjs.tz(`${new Date().getFullYear()}-07-01T00:00:00`, timeZone);
return jul.utcOffset() - jan.utcOffset();
};

/**
* Get UTC offset of given time zone when in DST
* @param timeZone Time Zone Name (Ex. America/Mazatlan)
* @returns minutes
*/
export const getUTCOffsetInDST = (timeZone: string) => {
if (timeZoneWithDST(timeZone)) {
const jan = dayjs.tz(`${new Date().getFullYear()}-01-01T00:00:00`, timeZone);
const jul = dayjs.tz(`${new Date().getFullYear()}-07-01T00:00:00`, timeZone);
return jan.utcOffset() < jul.utcOffset() ? jul.utcOffset() : jan.utcOffset();
}
return 0;
};
/**
* Verifies if given time zone is in DST
* @param date
* @returns
*/
export const isInDST = (date: Dayjs) => {
const timeZone = getTimeZone(date);

return timeZoneWithDST(timeZone) && date.utcOffset() === getUTCOffsetInDST(timeZone);
};
36 changes: 26 additions & 10 deletions packages/lib/slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import dayjs from "@calcom/dayjs";
import type { WorkingHours, TimeRange as DateOverride } from "@calcom/types/schedule";

import { getWorkingHours } from "./availability";
import { getTimeZone, isInDST, getDSTDifference } from "./date-fns";

export type GetSlots = {
inviteeDate: Dayjs;
Expand All @@ -11,6 +12,7 @@ export type GetSlots = {
dateOverrides?: DateOverride[];
minimumBookingNotice: number;
eventLength: number;
organizerTimeZone: string;
};
export type TimeFrame = { userIds?: number[]; startTime: number; endTime: number };

Expand All @@ -22,12 +24,16 @@ function buildSlots({
frequency,
eventLength,
startDate,
organizerTimeZone,
inviteeTimeZone,
}: {
computedLocalAvailability: TimeFrame[];
startOfInviteeDay: Dayjs;
startDate: Dayjs;
frequency: number;
eventLength: number;
organizerTimeZone: string;
inviteeTimeZone: string;
}) {
// no slots today
if (startOfInviteeDay.isBefore(startDate, "day")) {
Expand Down Expand Up @@ -92,11 +98,23 @@ function buildSlots({
});
}
}
// XXX: Hack alert, as dayjs is supposedly not aware of timezone the current slot may have invalid UTC offset.
const timeZone =
(startOfInviteeDay as unknown as { $x: { $timezone: string } })["$x"]["$timezone"] || "UTC";

const isOrganizerInDST = isInDST(startOfInviteeDay.tz(organizerTimeZone));
const isInviteeInDST = isInDST(startOfInviteeDay.tz(inviteeTimeZone));
const organizerDSTDifference = getDSTDifference(organizerTimeZone);
const inviteeDSTDifference = getDSTDifference(inviteeTimeZone);

const slots: { time: Dayjs; userIds?: number[] }[] = [];

roae marked this conversation as resolved.
Show resolved Hide resolved
const getTime = (time: number) => {
const minutes =
isOrganizerInDST !== isInviteeInDST
? time - (isOrganizerInDST ? organizerDSTDifference : -inviteeDSTDifference)
roae marked this conversation as resolved.
Show resolved Hide resolved
: time;

return startOfInviteeDay.tz(inviteeTimeZone).add(minutes, "minutes");
};

for (const item of Object.values(slotsTimeFrameAvailable)) {
/*
* @calcom/web:dev: 2022-11-06T00:00:00-04:00
Expand All @@ -108,7 +126,7 @@ function buildSlots({
*/
const slot = {
userIds: item.userIds,
time: dayjs.tz(startOfInviteeDay.add(item.startTime, "minute").format("YYYY-MM-DDTHH:mm:ss"), timeZone),
time: getTime(item.startTime),
};
// If the startOfInviteeDay has a different UTC offset than the slot, a DST change has occurred.
// As the time has now fallen backwards, or forwards; this difference -
Expand All @@ -132,6 +150,7 @@ const getSlots = ({
workingHours,
dateOverrides = [],
eventLength,
organizerTimeZone,
}: GetSlots) => {
// current date in invitee tz
const startDate = dayjs().utcOffset(inviteeDate.utcOffset()).add(minimumBookingNotice, "minute");
Expand All @@ -151,12 +170,7 @@ const getSlots = ({
return [];
}

// Dayjs does not expose the timeZone value publicly through .get("timeZone")
// instead, we as devs are required to somewhat hack our way to get the ...
// tz value as string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const timeZone: string = (inviteeDate as any)["$x"]["$timezone"];

const timeZone: string = getTimeZone(inviteeDate);
const workingHoursUTC = workingHours.map((schedule) => ({
userId: schedule.userId,
days: schedule.days,
Expand Down Expand Up @@ -237,6 +251,8 @@ const getSlots = ({
startDate,
frequency,
eventLength,
organizerTimeZone,
inviteeTimeZone: timeZone,
});
};

Expand Down
2 changes: 2 additions & 0 deletions packages/trpc/server/routers/viewer/slots.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, ctx:
dateOverrides,
minimumBookingNotice: eventType.minimumBookingNotice,
frequency: eventType.slotInterval || input.duration || eventType.length,
organizerTimeZone:
eventType.timeZone || eventType?.schedule?.timeZone || userAvailability?.[0]?.timeZone,
})
);
}
Expand Down