From 2d26d73eb348560f2faef059d0512cfcd436807a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efra=C3=ADn=20Roch=C3=ADn?= Date: Fri, 3 Mar 2023 09:33:16 -0700 Subject: [PATCH] Fix/DST issues (#7462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: Omar López * fixes typo * Apply suggestions from code review Co-authored-by: alannnc * cleaning --------- Co-authored-by: Alex van Andel Co-authored-by: Omar López Co-authored-by: alannnc --- .../components/booking/pages/BookingPage.tsx | 4 +- apps/web/test/lib/slots.test.ts | 5 ++ .../features/bookings/lib/handleNewBooking.ts | 38 +++++++++-- .../components/v2/TeamAvailabilityTimes.tsx | 5 +- packages/lib/date-fns/index.ts | 66 +++++++++++++++++++ packages/lib/slots.ts | 33 +++++++--- packages/trpc/server/routers/viewer/slots.tsx | 2 + 7 files changed, 134 insertions(+), 19 deletions(-) diff --git a/apps/web/components/booking/pages/BookingPage.tsx b/apps/web/components/booking/pages/BookingPage.tsx index c51a1d7e6c..525999b645 100644 --- a/apps/web/components/booking/pages/BookingPage.tsx +++ b/apps/web/components/booking/pages/BookingPage.tsx @@ -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(), diff --git a/apps/web/test/lib/slots.test.ts b/apps/web/test/lib/slots.test.ts index 3643c593f0..0c8407f859 100644 --- a/apps/web/test/lib/slots.test.ts +++ b/apps/web/test/lib/slots.test.ts @@ -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); }); diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index d353b8a75d..db41714499 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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> & { 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, diff --git a/packages/features/ee/teams/components/v2/TeamAvailabilityTimes.tsx b/packages/features/ee/teams/components/v2/TeamAvailabilityTimes.tsx index 43e4a4df7b..975d81e3f5 100644 --- a/packages/features/ee/teams/components/v2/TeamAvailabilityTimes.tsx +++ b/packages/features/ee/teams/components/v2/TeamAvailabilityTimes.tsx @@ -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}`, }) : []; diff --git a/packages/lib/date-fns/index.ts b/packages/lib/date-fns/index.ts index 57658bcb04..9d1b1744f6 100644 --- a/packages/lib/date-fns/index.ts +++ b/packages/lib/date-fns/index.ts @@ -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); +}; diff --git a/packages/lib/slots.ts b/packages/lib/slots.ts index 181749c1b2..e34c8b2413 100644 --- a/packages/lib/slots.ts +++ b/packages/lib/slots.ts @@ -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, }); }; diff --git a/packages/trpc/server/routers/viewer/slots.tsx b/packages/trpc/server/routers/viewer/slots.tsx index 95256dbe53..11493ec07e 100644 --- a/packages/trpc/server/routers/viewer/slots.tsx +++ b/packages/trpc/server/routers/viewer/slots.tsx @@ -317,6 +317,8 @@ export async function getSchedule(input: z.infer, ctx: dateOverrides, minimumBookingNotice: eventType.minimumBookingNotice, frequency: eventType.slotInterval || input.duration || eventType.length, + organizerTimeZone: + eventType.timeZone || eventType?.schedule?.timeZone || userAvailability?.[0]?.timeZone, }) ); }