diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/transformers/api-to-internal/api-to-internal.spec.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/transformers/api-to-internal/api-to-internal.spec.ts index 1e3233b794..7383079a4b 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/transformers/api-to-internal/api-to-internal.spec.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/transformers/api-to-internal/api-to-internal.spec.ts @@ -2,44 +2,44 @@ import { BookerLayoutsInputEnum_2024_06_14, BookerLayoutsOutputEnum_2024_06_14, ConfirmationPolicyEnum, - NoticeThresholdUnitEnum, FrequencyInput, + NoticeThresholdUnitEnum, } from "@calcom/platform-enums"; import type { - InputBookingField_2024_06_14, - InputLocation_2024_06_14, + BookerLayouts_2024_06_14, BookingLimitsCount_2024_06_14, BookingWindow_2024_06_14, - BookerLayouts_2024_06_14, ConfirmationPolicy_2024_06_14, - EventTypeColor_2024_06_14, - Recurrence_2024_06_14, CreateEventTypeInput_2024_06_14, - SeatOptionsTransformedSchema, - SeatOptionsDisabledSchema, + EventTypeColor_2024_06_14, InputAttendeeAddressLocation_2024_06_14, - InputAttendeePhoneLocation_2024_06_14, InputAttendeeDefinedLocation_2024_06_14, + InputAttendeePhoneLocation_2024_06_14, + InputBookingField_2024_06_14, + InputLocation_2024_06_14, InputTeamLocation_2024_06_14, + Recurrence_2024_06_14, + SeatOptionsDisabledSchema, + SeatOptionsTransformedSchema, } from "@calcom/platform-types"; import { - systemBeforeFieldEmail, - systemBeforeFieldName, type CustomField, type SystemField, + systemBeforeFieldEmail, + systemBeforeFieldName, } from "../internal-to-api/booking-fields"; import { - transformLocationsApiToInternal, + transformBookerLayoutsApiToInternal, transformBookingFieldsApiToInternal, - transformSelectOptionsApiToInternal, - transformIntervalLimitsApiToInternal, + transformConfirmationPolicyApiToInternal, + transformEventColorsApiToInternal, transformFutureBookingLimitsApiToInternal, + transformIntervalLimitsApiToInternal, + transformLocationsApiToInternal, transformRecurrenceApiToInternal, transformSeatsApiToInternal, - transformEventColorsApiToInternal, - transformBookerLayoutsApiToInternal, - transformConfirmationPolicyApiToInternal, + transformSelectOptionsApiToInternal, transformTeamLocationsApiToInternal, } from "./index"; @@ -182,10 +182,10 @@ describe("transformBookingFieldsApiToInternal", () => { disableOnPrefill: true, }; // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore + // @ts-expect-error expectedField.variantsConfig.variants.fullName.fields[0].label = "Your name number"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore + // @ts-expect-error expectedField.variantsConfig.variants.fullName.fields[0].placeholder = "123456789"; const expectedOutput: SystemField[] = [expectedField]; @@ -708,6 +708,62 @@ describe("transformFutureBookingLimitsApiToInternal", () => { expect(result).toEqual(expectedOutput); }); + it("should normalize range type with full ISO strings to UTC midnight (positive offset)", () => { + // Client in Dubai (UTC+4) sends full ISO strings - without normalization this would shift dates + // e.g., "2024-08-06T00:00:00.000+04:00" would become 2024-08-05T20:00:00.000Z + const input: BookingWindow_2024_06_14 = { + type: "range", + value: ["2024-08-06T00:00:00.000+04:00", "2024-08-28T00:00:00.000+04:00"], + }; + + // Should extract date part and create UTC midnight dates + const expectedOutput = { + periodType: "RANGE", + periodStartDate: new Date("2024-08-06"), + periodEndDate: new Date("2024-08-28"), + }; + + const result = transformFutureBookingLimitsApiToInternal(input); + + expect(result).toEqual(expectedOutput); + }); + it("should normalize range type with full ISO strings to UTC midnight (negative offset)", () => { + // Client in Buenos Aires (UTC-3) sends full ISO strings - without normalization this would shift dates + // e.g., "2024-08-06T00:00:00.000-03:00" would become 2024-08-06T03:00:00.000Z (3 hours later) + const input: BookingWindow_2024_06_14 = { + type: "range", + value: ["2024-08-06T00:00:00.000-03:00", "2024-08-28T00:00:00.000-03:00"], + }; + + // Should extract date part and create UTC midnight dates + const expectedOutput = { + periodType: "RANGE", + periodStartDate: new Date("2024-08-06"), + periodEndDate: new Date("2024-08-28"), + }; + + const result = transformFutureBookingLimitsApiToInternal(input); + + expect(result).toEqual(expectedOutput); + }); + it("should normalize range type with full ISO strings to UTC midnight (UTC/Z suffix)", () => { + // Client sends UTC timestamps with Z suffix + const input: BookingWindow_2024_06_14 = { + type: "range", + value: ["2024-08-06T00:00:00.000Z", "2024-08-28T00:00:00.000Z"], + }; + + // Should extract date part and create UTC midnight dates + const expectedOutput = { + periodType: "RANGE", + periodStartDate: new Date("2024-08-06"), + periodEndDate: new Date("2024-08-28"), + }; + + const result = transformFutureBookingLimitsApiToInternal(input); + + expect(result).toEqual(expectedOutput); + }); it("should transform calendar days", () => { const input: BookingWindow_2024_06_14 = { type: "calendarDays", diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/transformers/api-to-internal/future-booking-limits.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/transformers/api-to-internal/future-booking-limits.ts index 11b8fe48b5..64f347c469 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/transformers/api-to-internal/future-booking-limits.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/transformers/api-to-internal/future-booking-limits.ts @@ -3,8 +3,8 @@ import { BookingWindowPeriodOutputTypeEnum_2024_06_14, } from "@calcom/platform-enums"; import { - type CreateEventTypeInput_2024_06_14, type BusinessDaysWindow_2024_06_14, + type CreateEventTypeInput_2024_06_14, type RangeWindow_2024_06_14, type TransformFutureBookingsLimitSchema_2024_06_14, } from "@calcom/platform-types"; @@ -24,7 +24,7 @@ export function transformFutureBookingLimitsApiToInternal( case BookingWindowPeriodInputTypeEnum_2024_06_14.businessDays: return { periodDays: (inputBookingLimits as BusinessDaysWindow_2024_06_14).value, - periodType: !!(inputBookingLimits as BusinessDaysWindow_2024_06_14).rolling + periodType: (inputBookingLimits as BusinessDaysWindow_2024_06_14).rolling ? BookingWindowPeriodOutputTypeEnum_2024_06_14.ROLLING_WINDOW : BookingWindowPeriodOutputTypeEnum_2024_06_14.ROLLING, periodCountCalendarDays: false, @@ -32,17 +32,20 @@ export function transformFutureBookingLimitsApiToInternal( case BookingWindowPeriodInputTypeEnum_2024_06_14.calendarDays: return { periodDays: (inputBookingLimits as BusinessDaysWindow_2024_06_14).value, - periodType: !!(inputBookingLimits as BusinessDaysWindow_2024_06_14).rolling + periodType: (inputBookingLimits as BusinessDaysWindow_2024_06_14).rolling ? BookingWindowPeriodOutputTypeEnum_2024_06_14.ROLLING_WINDOW : BookingWindowPeriodOutputTypeEnum_2024_06_14.ROLLING, periodCountCalendarDays: true, }; - case BookingWindowPeriodInputTypeEnum_2024_06_14.range: + case BookingWindowPeriodInputTypeEnum_2024_06_14.range: { + const startDateStr = (inputBookingLimits as RangeWindow_2024_06_14).value[0].slice(0, 10); + const endDateStr = (inputBookingLimits as RangeWindow_2024_06_14).value[1].slice(0, 10); return { periodType: BookingWindowPeriodOutputTypeEnum_2024_06_14.RANGE, - periodStartDate: new Date((inputBookingLimits as RangeWindow_2024_06_14).value[0]), - periodEndDate: new Date((inputBookingLimits as RangeWindow_2024_06_14).value[1]), + periodStartDate: new Date(startDateStr), + periodEndDate: new Date(endDateStr), }; + } default: return undefined; } diff --git a/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts b/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts index 210d5ec089..375b65c89e 100644 --- a/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts +++ b/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts @@ -45,6 +45,25 @@ type EnabledAppType = App & { locationOption: LocationOption | null; }; +/** + * Normalizes a period date to UTC midnight. + * Atoms receives JSON where dates are strings (e.g., "2024-01-20T00:00:00.000Z" or "2024-01-20"), + * but TypeScript types them as Date. This function handles both cases. + * We extract the date part (YYYY-MM-DD) to avoid timezone shifts. + */ +function normalizePeriodDate(date: Date | string | null | undefined): Date | null | undefined { + if (date === undefined) return undefined; + if (date === null) return null; + + // Handle both string (from JSON) and Date object (if already parsed) + const dateStr = typeof date === "string" ? date : date.toISOString(); + + // Extract the date part (first 10 chars: YYYY-MM-DD) to avoid timezone shifts + // e.g., "2024-01-20T00:00:00.000+04:00" -> "2024-01-20" -> UTC midnight Jan 20 + const dateOnly = dateStr.slice(0, 10); + return new Date(dateOnly); +} + @Injectable() export class EventTypesAtomService { constructor( @@ -134,8 +153,21 @@ export class EventTypesAtomService { bookingFields.push(systemBeforeFieldEmail); } + // Normalize period dates to UTC midnight (only if provided) + const periodDates = + body.periodStartDate !== undefined || body.periodEndDate !== undefined + ? { + ...(body.periodStartDate !== undefined + ? { periodStartDate: normalizePeriodDate(body.periodStartDate) } + : {}), + ...(body.periodEndDate !== undefined + ? { periodEndDate: normalizePeriodDate(body.periodEndDate) } + : {}), + } + : {}; + const eventType = await updateEventType({ - input: { ...body, id: eventTypeId, bookingFields }, + input: { ...body, id: eventTypeId, bookingFields, ...periodDates }, ctx: { user: eventTypeUser, prisma: this.dbWrite.prisma, @@ -162,8 +194,21 @@ export class EventTypesAtomService { bookingFields.push(systemBeforeFieldEmail); } + // Normalize period dates to UTC midnight (only if provided) + const periodDates = + body.periodStartDate !== undefined || body.periodEndDate !== undefined + ? { + ...(body.periodStartDate !== undefined + ? { periodStartDate: normalizePeriodDate(body.periodStartDate) } + : {}), + ...(body.periodEndDate !== undefined + ? { periodEndDate: normalizePeriodDate(body.periodEndDate) } + : {}), + } + : {}; + const eventType = await updateEventType({ - input: { ...body, id: eventTypeId, bookingFields }, + input: { ...body, id: eventTypeId, bookingFields, ...periodDates }, ctx: { user: eventTypeUser, prisma: this.dbWrite.prisma, diff --git a/apps/web/modules/event-types/components/tabs/limits/EventLimitsTab.tsx b/apps/web/modules/event-types/components/tabs/limits/EventLimitsTab.tsx index e1c230fed0..bf29cd3b72 100644 --- a/apps/web/modules/event-types/components/tabs/limits/EventLimitsTab.tsx +++ b/apps/web/modules/event-types/components/tabs/limits/EventLimitsTab.tsx @@ -1,16 +1,12 @@ -import { useAutoAnimate } from "@formkit/auto-animate/react"; -import * as RadioGroup from "@radix-ui/react-radio-group"; -import type { Key } from "react"; -import React, { useEffect, useState } from "react"; -import type { UseFormRegisterReturn, UseFormReturn } from "react-hook-form"; -import { Controller, useFormContext } from "react-hook-form"; -import type { SingleValue } from "react-select"; - import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; -import { LearnMoreLink } from "@calcom/web/modules/event-types/components/LearnMoreLink"; import { getDefinedBufferTimes } from "@calcom/features/eventtypes/lib/getDefinedBufferTimes"; -import type { FormValues, EventTypeSetupProps, InputClassNames } from "@calcom/features/eventtypes/lib/types"; -import type { SelectClassNames, SettingsToggleClassNames } from "@calcom/features/eventtypes/lib/types"; +import type { + EventTypeSetupProps, + FormValues, + InputClassNames, + SelectClassNames, + SettingsToggleClassNames, +} from "@calcom/features/eventtypes/lib/types"; import CheckboxField from "@calcom/features/form/components/CheckboxField"; import { ROLLING_WINDOW_PERIOD_MAX_DAYS_TO_CHECK } from "@calcom/lib/constants"; import type { DurationType } from "@calcom/lib/convertToNewDurationType"; @@ -23,15 +19,23 @@ import { PeriodType } from "@calcom/prisma/enums"; import classNames from "@calcom/ui/classNames"; import { Button } from "@calcom/ui/components/button"; import { - InputField, DateRangePicker, + InputField, Label, - TextField, Select, SettingsToggle, + TextField, } from "@calcom/ui/components/form"; import { Icon } from "@calcom/ui/components/icon"; import { Tooltip } from "@calcom/ui/components/tooltip"; +import { LearnMoreLink } from "@calcom/web/modules/event-types/components/LearnMoreLink"; +import { useAutoAnimate } from "@formkit/auto-animate/react"; +import * as RadioGroup from "@radix-ui/react-radio-group"; +import type { Key } from "react"; +import React, { useEffect, useState } from "react"; +import type { UseFormRegisterReturn, UseFormReturn } from "react-hook-form"; +import { Controller, useFormContext } from "react-hook-form"; +import type { SingleValue } from "react-select"; import MaxActiveBookingsPerBookerController from "./MaxActiveBookingsPerBookerController"; @@ -158,9 +162,14 @@ function RangeLimitRadioItem({ }} disabled={isDisabled} onDatesChange={({ startDate, endDate }) => { + const toUTCMidnight = (date: Date | undefined): Date | undefined => { + if (!date) return undefined; + return new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + }; + onChange({ - startDate, - endDate, + startDate: toUTCMidnight(startDate), + endDate: toUTCMidnight(endDate), }); }} className={customClassNames?.datePicker} @@ -965,9 +974,8 @@ export const IntervalLimitsManager = { { id: 1, length: 60, - // Makes today and tomorrow only available + // Makes plus1 and plus2 dates only available (June 1-2 in event timezone) + // Period dates are now stored as UTC midnight for the selected date ...getPeriodTypeData({ type: "RANGE", - // dayPlus1InIst - periodStartDate: new Date(`${todayDateString}T18:30:00.000Z`), - // datePlus2InIst - periodEndDate: new Date(`${plus1DateString}T18:30:00.000Z`), + periodStartDate: new Date(`${plus1DateString}T00:00:00.000Z`), // June 1 UTC midnight + periodEndDate: new Date(`${plus2DateString}T00:00:00.000Z`), // June 2 UTC midnight periodCountCalendarDays: true, }), users: [ @@ -1574,14 +1573,12 @@ describe("getSchedule", () => { { id: 1, length: 60, - // Makes today and tomorrow only available + // Makes 25th and 26th July (in event timezone) available + // Period dates are now stored as UTC midnight for the selected date ...getPeriodTypeData({ type: "RANGE", - - // Only 25th and 26th(as per the event timezone(IST)) should be available - periodStartDate: new Date(`2024-07-24T18:30:00.000Z`), // 25th July in IST - periodEndDate: new Date(`2024-07-25T18:30:00.000Z`), // 26th July in IST - + periodStartDate: new Date(`2024-07-25T00:00:00.000Z`), // July 25 UTC midnight + periodEndDate: new Date(`2024-07-26T00:00:00.000Z`), // July 26 UTC midnight periodCountCalendarDays: true, }), users: [ @@ -1614,23 +1611,17 @@ describe("getSchedule", () => { orgSlug: null, }, }); + expect(scheduleForEventForPagoTz).toHaveDateDisabled({ + dateString: "2024-07-21", + }); - /** - * Current day in test is 5th July, so verify that earlier timeslots than 24th July are disabled - */ - { - expect(scheduleForEventForPagoTz).toHaveDateDisabled({ - dateString: "2024-07-21", - }); + expect(scheduleForEventForPagoTz).toHaveDateDisabled({ + dateString: "2024-07-22", + }); - expect(scheduleForEventForPagoTz).toHaveDateDisabled({ - dateString: "2024-07-22", - }); - - expect(scheduleForEventForPagoTz).toHaveDateDisabled({ - dateString: "2024-07-23", - }); - } + expect(scheduleForEventForPagoTz).toHaveDateDisabled({ + dateString: "2024-07-23", + }); expect(scheduleForEventForPagoTz).toHaveTimeSlots( [ @@ -1670,19 +1661,13 @@ describe("getSchedule", () => { dateString: "2024-07-26", doExactMatch: true, }); + expect(scheduleForEventForPagoTz).toHaveDateDisabled({ + dateString: "2024-07-27", + }); - /** - * Verify that timeslots beyond 26th July are disabled - */ - { - expect(scheduleForEventForPagoTz).toHaveDateDisabled({ - dateString: "2024-07-27", - }); - - expect(scheduleForEventForPagoTz).toHaveDateDisabled({ - dateString: "2024-07-28", - }); - } + expect(scheduleForEventForPagoTz).toHaveDateDisabled({ + dateString: "2024-07-28", + }); }); }); }); diff --git a/packages/lib/isOutOfBounds.timezone.test.ts b/packages/lib/isOutOfBounds.timezone.test.ts index 2f3f5bd2c0..7de8d775a1 100644 --- a/packages/lib/isOutOfBounds.timezone.test.ts +++ b/packages/lib/isOutOfBounds.timezone.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from "vitest"; +process.env.TZ = "Asia/Dubai"; import dayjs from "@calcom/dayjs"; - -import { getRollingWindowEndDate } from "./isOutOfBounds"; +import { PeriodType } from "@calcom/prisma/enums"; +import { describe, expect, it } from "vitest"; +import { calculatePeriodLimits, getRollingWindowEndDate } from "./isOutOfBounds"; const getDayJsTimeWithUtcOffset = ({ dateStringWithOffset, @@ -231,3 +232,428 @@ describe("getRollingWindowEndDate", () => { expect(endDay?.format()).toEqual("2024-07-01T23:59:59+11:00"); }); }); + +describe("calculatePeriodLimits - PeriodType.RANGE", () => { + /** + * BUG FIX TEST + * + * The fix ensures that when a user selects a date (e.g., Jan 20) in the UI, + * the frontend converts it to UTC midnight for that date before sending to the server. + * + * Example: User in Dubai (UTC+4) selects Jan 20 + * - OLD (buggy): Frontend sent midnight Jan 20 Dubai = 2024-01-19T20:00:00Z + * This caused the wrong date to be stored and slots to appear on Jan 19 instead of Jan 20 + * - NEW (fixed): Frontend sends 2024-01-20T00:00:00Z (UTC midnight for Jan 20) + * This ensures the correct date is stored regardless of user's timezone + */ + + /** + * Helper to simulate OLD buggy frontend behavior: + * Creates a Date representing midnight of the given date in user's local timezone + * (This is what the DateRangePicker used to return before the fix) + */ + function createLocalMidnight(year: number, month: number, day: number): Date { + // month is 0-indexed (0 = January) + return new Date(year, month, day, 0, 0, 0, 0); + } + + /** + * Helper to simulate NEW fixed frontend behavior: + * Creates a Date representing midnight of the given date in UTC + * (This is what the frontend now sends after the fix) + */ + function createUTCMidnight(year: number, month: number, day: number): Date { + // month is 0-indexed (0 = January) + return new Date(Date.UTC(year, month, day, 0, 0, 0, 0)); + } + + describe("demonstrates the bug that was fixed", () => { + it("OLD buggy frontend: user selects Jan 20 but range covers Jan 19 (WRONG)", () => { + // User in Dubai (UTC+4) selects Jan 20 in the UI + // OLD frontend created: new Date(2024, 0, 20) = midnight Jan 20 Dubai + // Which is 2024-01-19T20:00:00Z in UTC + const buggyPeriodStartDate = createLocalMidnight(2024, 0, 20); // Jan 20 local + const buggyPeriodEndDate = createLocalMidnight(2024, 0, 20); + + // Verify the buggy date is NOT Jan 20 UTC (it's Jan 19 20:00 UTC) + // This test runs with TZ=Asia/Dubai, so local midnight Jan 20 = Jan 19 20:00 UTC + expect(buggyPeriodStartDate.toISOString()).toBe("2024-01-19T20:00:00.000Z"); + + // Event timezone: Europe/London (UTC+0) + const eventUtcOffset = 0; + + const result = calculatePeriodLimits({ + periodType: PeriodType.RANGE, + periodDays: null, + periodCountCalendarDays: null, + periodStartDate: buggyPeriodStartDate, + periodEndDate: buggyPeriodEndDate, + allDatesWithBookabilityStatusInBookerTz: null, + eventUtcOffset, + bookerUtcOffset: 240, + }); + + // With buggy input, the range covers Jan 19 instead of Jan 20! + // This is the BUG - user selected Jan 20 but slots appear on Jan 19 + expect(result.startOfRangeStartDayInEventTz!.date()).toBe(19); // WRONG! Should be 20 + expect(result.endOfRangeEndDayInEventTz!.date()).toBe(19); // WRONG! Should be 20 + }); + + it("NEW fixed frontend: user selects Jan 20 and range correctly covers Jan 20", () => { + // User in Dubai (UTC+4) selects Jan 20 in the UI + // NEW frontend creates: Date.UTC(2024, 0, 20) = midnight Jan 20 UTC + // Which is 2024-01-20T00:00:00Z + const fixedPeriodStartDate = createUTCMidnight(2024, 0, 20); // Jan 20 UTC + const fixedPeriodEndDate = createUTCMidnight(2024, 0, 20); + + // Verify the fixed date IS Jan 20 UTC midnight + expect(fixedPeriodStartDate.toISOString()).toBe("2024-01-20T00:00:00.000Z"); + + // Event timezone: Europe/London (UTC+0) + const eventUtcOffset = 0; + + const result = calculatePeriodLimits({ + periodType: PeriodType.RANGE, + periodDays: null, + periodCountCalendarDays: null, + periodStartDate: fixedPeriodStartDate, + periodEndDate: fixedPeriodEndDate, + allDatesWithBookabilityStatusInBookerTz: null, + eventUtcOffset, + bookerUtcOffset: 240, + }); + + // With fixed input, the range correctly covers Jan 20 + expect(result.startOfRangeStartDayInEventTz!.date()).toBe(20); // CORRECT! + expect(result.endOfRangeEndDayInEventTz!.date()).toBe(20); // CORRECT! + }); + }); + + it("should calculate period range for Jan 20 correctly when stored as UTC midnight", () => { + // User selects Jan 20 in the UI (any timezone) + // Frontend now correctly sends UTC midnight for Jan 20 + const periodStartDate = new Date("2024-01-20T00:00:00.000Z"); + const periodEndDate = new Date("2024-01-20T00:00:00.000Z"); + + // Event timezone: Europe/London (UTC+0 in winter) + const eventUtcOffset = 0; + const bookerUtcOffset = 240; // Dubai UTC+4 + + const result = calculatePeriodLimits({ + periodType: PeriodType.RANGE, + periodDays: null, + periodCountCalendarDays: null, + periodStartDate, + periodEndDate, + allDatesWithBookabilityStatusInBookerTz: null, + eventUtcOffset, + bookerUtcOffset, + }); + + expect(result.startOfRangeStartDayInEventTz).not.toBeNull(); + expect(result.endOfRangeEndDayInEventTz).not.toBeNull(); + + // Range should cover Jan 20 in event timezone (UTC) + // - Start: 2024-01-20T00:00:00Z + // - End: 2024-01-20T23:59:59.999Z + const startUTC = result.startOfRangeStartDayInEventTz!.toISOString(); + const endUTC = result.endOfRangeEndDayInEventTz!.toISOString(); + + expect(startUTC).toBe("2024-01-20T00:00:00.000Z"); + expect(endUTC).toBe("2024-01-20T23:59:59.999Z"); + + // Verify date is Jan 20 + expect(result.startOfRangeStartDayInEventTz!.date()).toBe(20); + expect(result.endOfRangeEndDayInEventTz!.date()).toBe(20); + }); + + it("should calculate period range correctly with non-UTC event timezone", () => { + // User selects Jan 20 in the UI + // Frontend sends UTC midnight for Jan 20 + const periodStartDate = new Date("2024-01-20T00:00:00.000Z"); + const periodEndDate = new Date("2024-01-20T00:00:00.000Z"); + + // Event timezone: Jerusalem (UTC+2) + const eventUtcOffset = 120; // minutes + const bookerUtcOffset = 240; // Dubai UTC+4 + + const result = calculatePeriodLimits({ + periodType: PeriodType.RANGE, + periodDays: null, + periodCountCalendarDays: null, + periodStartDate, + periodEndDate, + allDatesWithBookabilityStatusInBookerTz: null, + eventUtcOffset, + bookerUtcOffset, + }); + + expect(result.startOfRangeStartDayInEventTz).not.toBeNull(); + expect(result.endOfRangeEndDayInEventTz).not.toBeNull(); + + // The stored timestamp 2024-01-20T00:00:00Z in Jerusalem (UTC+2) is: + // 2024-01-20T02:00:00+02:00 (Jan 20 at 2am Jerusalem) + // So the date in event timezone is Jan 20 + // + // Range should cover Jan 20 in Jerusalem timezone: + // - Start: 2024-01-20T00:00:00+02:00 = 2024-01-19T22:00:00Z + // - End: 2024-01-20T23:59:59+02:00 = 2024-01-20T21:59:59Z + const startUTC = result.startOfRangeStartDayInEventTz!.toISOString(); + const endUTC = result.endOfRangeEndDayInEventTz!.toISOString(); + + expect(startUTC).toBe("2024-01-19T22:00:00.000Z"); + expect(endUTC).toBe("2024-01-20T21:59:59.999Z"); + + // Verify date is Jan 20 in event timezone + expect(result.startOfRangeStartDayInEventTz!.date()).toBe(20); + expect(result.endOfRangeEndDayInEventTz!.date()).toBe(20); + }); + + it("should produce valid 24-hour range regardless of event timezone", () => { + // User selects Jan 20 + const periodStartDate = new Date("2024-01-20T00:00:00.000Z"); + const periodEndDate = new Date("2024-01-20T00:00:00.000Z"); + + // Test with various event timezones + const timezones = [ + { name: "UTC", offset: 0 }, + { name: "Jerusalem (UTC+2)", offset: 120 }, + { name: "Dubai (UTC+4)", offset: 240 }, + { name: "Tokyo (UTC+9)", offset: 540 }, + { name: "New York (UTC-5)", offset: -300 }, + ]; + + for (const tz of timezones) { + const result = calculatePeriodLimits({ + periodType: PeriodType.RANGE, + periodDays: null, + periodCountCalendarDays: null, + periodStartDate, + periodEndDate, + allDatesWithBookabilityStatusInBookerTz: null, + eventUtcOffset: tz.offset, + bookerUtcOffset: 0, + }); + + expect(result.startOfRangeStartDayInEventTz).not.toBeNull(); + expect(result.endOfRangeEndDayInEventTz).not.toBeNull(); + + // Range should be exactly 24 hours minus 1 millisecond + const duration = + result.endOfRangeEndDayInEventTz!.valueOf() - result.startOfRangeStartDayInEventTz!.valueOf(); + expect(duration).toBe(86400000 - 1); // 24 hours - 1ms + } + }); + + it("should handle single-day range (start equals end)", () => { + // User selects Jan 20 to Jan 20 (single day) + const periodStartDate = new Date("2024-01-20T00:00:00.000Z"); + const periodEndDate = new Date("2024-01-20T00:00:00.000Z"); + + const eventUtcOffset = 120; // Jerusalem + const bookerUtcOffset = 240; // Dubai + + const result = calculatePeriodLimits({ + periodType: PeriodType.RANGE, + periodDays: null, + periodCountCalendarDays: null, + periodStartDate, + periodEndDate, + allDatesWithBookabilityStatusInBookerTz: null, + eventUtcOffset, + bookerUtcOffset, + }); + + // Start and end should be on the same date + expect(result.startOfRangeStartDayInEventTz!.date()).toBe(result.endOfRangeEndDayInEventTz!.date()); + + // Range should still be valid (end > start) + expect(result.endOfRangeEndDayInEventTz!.valueOf()).toBeGreaterThan( + result.startOfRangeStartDayInEventTz!.valueOf() + ); + }); + + it("should handle multi-day range correctly", () => { + // User selects Jan 20 to Jan 25 + const periodStartDate = new Date("2024-01-20T00:00:00.000Z"); + const periodEndDate = new Date("2024-01-25T00:00:00.000Z"); + + const eventUtcOffset = 120; // Jerusalem + const bookerUtcOffset = 0; + + const result = calculatePeriodLimits({ + periodType: PeriodType.RANGE, + periodDays: null, + periodCountCalendarDays: null, + periodStartDate, + periodEndDate, + allDatesWithBookabilityStatusInBookerTz: null, + eventUtcOffset, + bookerUtcOffset, + }); + + // Start should be Jan 20, end should be Jan 25 + expect(result.startOfRangeStartDayInEventTz!.date()).toBe(20); + expect(result.endOfRangeEndDayInEventTz!.date()).toBe(25); + + // Range should span 6 days (20, 21, 22, 23, 24, 25) + const durationDays = + (result.endOfRangeEndDayInEventTz!.valueOf() - result.startOfRangeStartDayInEventTz!.valueOf()) / + (24 * 60 * 60 * 1000); + expect(Math.ceil(durationDays)).toBe(6); + }); + + describe("timezone combinations that were causing date shifts", () => { + /** + * These tests verify the specific timezone combinations reported as buggy: + * 1. Event timezone: UTC (offset 0), Booker: Dubai → date was shifting back a day + * 2. Event timezone: Buenos Aires (UTC-3, offset -180), Booker: Dubai → date was shifting back a day + * 3. Event timezone: Jerusalem (UTC+2, offset 120), Booker: Dubai → was working correctly + * + * The issue was that when we parse UTC midnight and convert to a negative offset timezone, + * the local time becomes the previous day, causing startOf("day") to give the wrong date. + */ + + it("Event timezone UTC (offset 0), Booker Dubai: should show Jan 20 correctly", () => { + // User selects Jan 20 in the UI + // Frontend sends UTC midnight for Jan 20 + const periodStartDate = new Date("2024-01-20T00:00:00.000Z"); + const periodEndDate = new Date("2024-01-20T00:00:00.000Z"); + + const eventUtcOffset = 0; // UTC + const bookerUtcOffset = 240; // Dubai UTC+4 + + const result = calculatePeriodLimits({ + periodType: PeriodType.RANGE, + periodDays: null, + periodCountCalendarDays: null, + periodStartDate, + periodEndDate, + allDatesWithBookabilityStatusInBookerTz: null, + eventUtcOffset, + bookerUtcOffset, + }); + + // Should be Jan 20, NOT Jan 19 + expect(result.startOfRangeStartDayInEventTz!.date()).toBe(20); + expect(result.endOfRangeEndDayInEventTz!.date()).toBe(20); + + // Start should be midnight UTC Jan 20 + expect(result.startOfRangeStartDayInEventTz!.toISOString()).toBe("2024-01-20T00:00:00.000Z"); + }); + + it("Event timezone Buenos Aires (UTC-3, offset -180), Booker Dubai: should show Jan 20 correctly", () => { + // User selects Jan 20 in the UI + // Frontend sends UTC midnight for Jan 20 + const periodStartDate = new Date("2024-01-20T00:00:00.000Z"); + const periodEndDate = new Date("2024-01-20T00:00:00.000Z"); + + const eventUtcOffset = -180; // Buenos Aires UTC-3 + const bookerUtcOffset = 240; // Dubai UTC+4 + + const result = calculatePeriodLimits({ + periodType: PeriodType.RANGE, + periodDays: null, + periodCountCalendarDays: null, + periodStartDate, + periodEndDate, + allDatesWithBookabilityStatusInBookerTz: null, + eventUtcOffset, + bookerUtcOffset, + }); + + // Should be Jan 20, NOT Jan 19 + // The bug was: UTC midnight converted to Buenos Aires = Jan 19 21:00, startOf("day") = Jan 19 + expect(result.startOfRangeStartDayInEventTz!.date()).toBe(20); + expect(result.endOfRangeEndDayInEventTz!.date()).toBe(20); + + // Start should be midnight Jan 20 in Buenos Aires = 03:00 UTC + expect(result.startOfRangeStartDayInEventTz!.toISOString()).toBe("2024-01-20T03:00:00.000Z"); + }); + + it("Event timezone Jerusalem (UTC+2, offset 120), Booker Dubai: should show Jan 20 correctly", () => { + // User selects Jan 20 in the UI + // Frontend sends UTC midnight for Jan 20 + const periodStartDate = new Date("2024-01-20T00:00:00.000Z"); + const periodEndDate = new Date("2024-01-20T00:00:00.000Z"); + + const eventUtcOffset = 120; // Jerusalem UTC+2 + const bookerUtcOffset = 240; // Dubai UTC+4 + + const result = calculatePeriodLimits({ + periodType: PeriodType.RANGE, + periodDays: null, + periodCountCalendarDays: null, + periodStartDate, + periodEndDate, + allDatesWithBookabilityStatusInBookerTz: null, + eventUtcOffset, + bookerUtcOffset, + }); + + // Should be Jan 20 (this was already working) + expect(result.startOfRangeStartDayInEventTz!.date()).toBe(20); + expect(result.endOfRangeEndDayInEventTz!.date()).toBe(20); + + // Start should be midnight Jan 20 in Jerusalem = 22:00 UTC Jan 19 + expect(result.startOfRangeStartDayInEventTz!.toISOString()).toBe("2024-01-19T22:00:00.000Z"); + }); + }); + + describe("backward compatibility with old format data", () => { + it("Old format: Dubai user selected Jan 20 with Dubai event timezone (two bugs canceled out)", () => { + // Before this PR, frontend stored dates in browser timezone + // Dubai user selecting Jan 20 stored as midnight Jan 20 Dubai = 2024-01-19T20:00:00Z + // This "accidentally worked" when event timezone matched browser timezone + const periodStartDate = new Date("2024-01-19T20:00:00.000Z"); // Midnight Jan 20 Dubai (old format) + const periodEndDate = new Date("2024-01-19T20:00:00.000Z"); + + const eventUtcOffset = 240; // Dubai UTC+4 (matches where the user selected the date) + const bookerUtcOffset = 240; + + const result = calculatePeriodLimits({ + periodType: PeriodType.RANGE, + periodDays: null, + periodCountCalendarDays: null, + periodStartDate, + periodEndDate, + allDatesWithBookabilityStatusInBookerTz: null, + eventUtcOffset, + bookerUtcOffset, + }); + + // Old format is detected (time is not 00:00:00 UTC) + // Legacy behavior: convert to event timezone → Jan 20 00:00 Dubai → correct! + expect(result.startOfRangeStartDayInEventTz!.date()).toBe(20); + expect(result.endOfRangeEndDayInEventTz!.date()).toBe(20); + }); + + it("Old format: Dubai user selected Jan 20 with UTC event timezone (was already broken, stays broken)", () => { + // Before this PR, this case was already broken + // Dubai user selecting Jan 20 stored as midnight Jan 20 Dubai = 2024-01-19T20:00:00Z + // With UTC event timezone, this converts to Jan 19 20:00 UTC → startOf day → Jan 19 + const periodStartDate = new Date("2024-01-19T20:00:00.000Z"); // Midnight Jan 20 Dubai (old format) + const periodEndDate = new Date("2024-01-19T20:00:00.000Z"); + + const eventUtcOffset = 0; // UTC event timezone + const bookerUtcOffset = 240; // Dubai booker + + const result = calculatePeriodLimits({ + periodType: PeriodType.RANGE, + periodDays: null, + periodCountCalendarDays: null, + periodStartDate, + periodEndDate, + allDatesWithBookabilityStatusInBookerTz: null, + eventUtcOffset, + bookerUtcOffset, + }); + + // Old format is detected (time is not 00:00:00 UTC) + // Legacy behavior: convert to UTC → Jan 19 20:00 UTC → startOf day → Jan 19 + // This was already broken before, and stays broken (no regression) + expect(result.startOfRangeStartDayInEventTz!.date()).toBe(19); + expect(result.endOfRangeEndDayInEventTz!.date()).toBe(19); + }); + }); +}); diff --git a/packages/lib/isOutOfBounds.tsx b/packages/lib/isOutOfBounds.tsx index fe7d4bc9c6..d385227d3c 100644 --- a/packages/lib/isOutOfBounds.tsx +++ b/packages/lib/isOutOfBounds.tsx @@ -112,8 +112,43 @@ export function calculatePeriodLimits({ // We take the start of the day for the start of the range and endOf the day for end of range, so that entire days are covered // We use organizer's timezone here(in contrast with ROLLING/ROLLING_WINDOW where number of days is available and not the specific date objects). // This is because in case of range the start and end date objects are determined by the organizer, so we should consider the range in organizer/event's timezone. - const startOfRangeStartDayInEventTz = dayjs(periodStartDate).utcOffset(eventUtcOffset).startOf("day"); - const endOfRangeEndDayInEventTz = dayjs(periodEndDate).utcOffset(eventUtcOffset).endOf("day"); + const startUtc = dayjs.utc(periodStartDate); + const endUtc = dayjs.utc(periodEndDate); + + // Detect if dates are in new format (UTC midnight) or old format (browser timezone midnight converted to UTC) + // New format: 2024-01-20T00:00:00.000Z (time is exactly midnight UTC) + // Old format: 2024-01-19T20:00:00.000Z (time is not midnight UTC - it's midnight in some browser timezone) + const isNewFormat = (date: dayjs.Dayjs) => + date.hour() === 0 && date.minute() === 0 && date.second() === 0 && date.millisecond() === 0; + + let startOfRangeStartDayInEventTz: dayjs.Dayjs; + let endOfRangeEndDayInEventTz: dayjs.Dayjs; + + if (isNewFormat(startUtc) && isNewFormat(endUtc)) { + // New format: dates are stored as UTC midnight (e.g., 2024-01-20T00:00:00Z for Jan 20) + // Extract the date and create midnight in the event timezone + // Formula: midnight in event timezone = midnight UTC - offset + // E.g., for UTC+2: midnight = 22:00 UTC (2 hours earlier) + // E.g., for UTC-3: midnight = 03:00 UTC (3 hours later) + const startDateStr = startUtc.format("YYYY-MM-DD"); + const endDateStr = endUtc.format("YYYY-MM-DD"); + + startOfRangeStartDayInEventTz = dayjs + .utc(`${startDateStr}T00:00:00Z`) + .subtract(eventUtcOffset, "minute") + .utcOffset(eventUtcOffset); + + endOfRangeEndDayInEventTz = dayjs + .utc(`${endDateStr}T00:00:00Z`) + .subtract(eventUtcOffset, "minute") + .utcOffset(eventUtcOffset) + .endOf("day"); + } else { + // Old format: dates were stored as browser timezone midnight converted to UTC + // Convert to event timezone and take start/end of day (legacy behavior) + startOfRangeStartDayInEventTz = dayjs(periodStartDate).utcOffset(eventUtcOffset).startOf("day"); + endOfRangeEndDayInEventTz = dayjs(periodEndDate).utcOffset(eventUtcOffset).endOf("day"); + } return { endOfRollingPeriodEndDayInBookerTz: null,