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:
co-authored by
Alex van Andel
Omar López
alannnc
parent
b8b24c04dc
commit
2d26d73eb3
@@ -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(),
|
||||
|
||||
@@ -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}`,
|
||||
})
|
||||
: [];
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user