Fix/DST issues (#7462)

* use the organizer timezone and invitee timezone to show slots

* type fixes

* set start date on selected TZ on booking payload

* add considerations when only the invitee is on DTS

* Apply suggestions from code review

Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Omar López <zomars@me.com>

* fixes typo

* Apply suggestions from code review

Co-authored-by: alannnc <alannnc@gmail.com>

* cleaning

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: alannnc <alannnc@gmail.com>
This commit is contained in:
Efraín Rochín
2023-03-03 09:33:16 -07:00
committed by GitHub
co-authored by Alex van Andel Omar López alannnc
parent b8b24c04dc
commit 2d26d73eb3
7 changed files with 134 additions and 19 deletions
@@ -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(),
+5
View File
@@ -24,6 +24,7 @@ describe("Tests the slot logic", () => {
},
],
eventLength: 60,
organizerTimeZone: "America/Toronto",
})
).toHaveLength(24);
});
@@ -45,6 +46,7 @@ describe("Tests the slot logic", () => {
},
],
eventLength: 60,
organizerTimeZone: "America/Toronto",
})
).toHaveLength(12);
});
@@ -64,6 +66,7 @@ describe("Tests the slot logic", () => {
},
],
eventLength: 60,
organizerTimeZone: "America/Toronto",
})
).toHaveLength(0);
});
@@ -84,6 +87,7 @@ describe("Tests the slot logic", () => {
minimumBookingNotice: 0,
workingHours,
eventLength: 60,
organizerTimeZone: "America/Toronto",
})
).toHaveLength(0);
});
@@ -104,6 +108,7 @@ describe("Tests the slot logic", () => {
},
],
eventLength: 60,
organizerTimeZone: "America/Toronto",
})
).toHaveLength(11);
});
@@ -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,
@@ -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";
@@ -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 === isInviteeInDSTWhenSlotStart;
const getTime = (slotTime: Dayjs, minutes: number) =>
slotTime
.startOf("day")
.add(
sameDSTUsers && organizerDST && inviteeDST
? minutes
: minutes -
(isOrganizerInDSTWhenSlotStart || isOrganizerInDST
? organizerDSTDifference
: inviteeDSTDifference),
"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.
@@ -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;
@@ -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,
}
)
) {
@@ -567,6 +594,7 @@ async function handler(
{
dateFrom: reqBody.start,
dateTo: reqBody.end,
timeZone: reqBody.timeZone,
},
{
allRecurringDates,
@@ -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";
@@ -42,6 +42,7 @@ export default function TeamAvailabilityTimes(props: Props) {
workingHours: data?.workingHours || [],
minimumBookingNotice: 0,
eventLength: props.frequency,
organizerTimeZone: `${data?.timeZone}`,
})
: [];
+66
View File
@@ -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
* 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);
};
+23 -10
View File
@@ -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;
@@ -11,6 +12,7 @@ export type GetSlots = {
dateOverrides?: DateOverride[];
minimumBookingNotice: number;
eventLength: number;
organizerTimeZone: string;
};
export type TimeFrame = { userIds?: number[]; startTime: number; endTime: number };
@@ -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")) {
@@ -92,11 +98,20 @@ 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[] }[] = [];
const resultDSTDifference = isOrganizerInDST ? organizerDSTDifference : -inviteeDSTDifference;
const getTime = (time: number) => {
const minutes = isOrganizerInDST !== isInviteeInDST ? time - resultDSTDifference : 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
@@ -108,7 +123,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 -
@@ -132,6 +147,7 @@ const getSlots = ({
workingHours,
dateOverrides = [],
eventLength,
organizerTimeZone,
}: GetSlots) => {
// current date in invitee tz
const startDate = dayjs().utcOffset(inviteeDate.utcOffset()).add(minimumBookingNotice, "minute");
@@ -151,12 +167,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,
@@ -237,6 +248,8 @@ const getSlots = ({
startDate,
frequency,
eventLength,
organizerTimeZone,
inviteeTimeZone: timeZone,
});
};
@@ -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,
})
);
}