diff --git a/packages/features/bookings/Booker/components/hooks/usePrefetch.ts b/packages/features/bookings/Booker/components/hooks/usePrefetch.ts deleted file mode 100644 index 3f746aaf977fb9..00000000000000 --- a/packages/features/bookings/Booker/components/hooks/usePrefetch.ts +++ /dev/null @@ -1,47 +0,0 @@ -import dayjs from "@calcom/dayjs"; -import type { BookerState } from "@calcom/features/bookings/Booker/types"; - -import { getPrefetchMonthCount } from "../../utils/getPrefetchMonthCount"; -import { isPrefetchNextMonthEnabled } from "../../utils/isPrefetchNextMonthEnabled"; - -interface UsePrefetchParams { - date: string; - month: string | null; - bookerLayout: { - layout: string; - extraDays: number; - columnViewExtraDays: { current: number }; - }; - bookerState: BookerState; -} - -export const usePrefetch = ({ date, month, bookerLayout, bookerState }: UsePrefetchParams) => { - const dateMonth = dayjs(date).month(); - const monthAfterAdding1Month = dayjs(date).add(1, "month").month(); - const monthAfterAddingExtraDays = dayjs(date).add(bookerLayout.extraDays, "day").month(); - const monthAfterAddingExtraDaysColumnView = dayjs(date) - .add(bookerLayout.columnViewExtraDays.current, "day") - .month(); - - const prefetchNextMonth = isPrefetchNextMonthEnabled( - bookerLayout.layout, - date, - dateMonth, - monthAfterAddingExtraDays, - monthAfterAddingExtraDaysColumnView, - month, - bookerLayout.extraDays - ); - const monthCount = getPrefetchMonthCount( - bookerLayout.layout, - bookerState, - monthAfterAdding1Month, - monthAfterAddingExtraDaysColumnView, - prefetchNextMonth - ); - - return { - prefetchNextMonth, - monthCount, - }; -}; diff --git a/packages/features/bookings/Booker/utils/event.ts b/packages/features/bookings/Booker/utils/event.ts index 450ac6ee9c2d8a..e4b1b988c5f095 100644 --- a/packages/features/bookings/Booker/utils/event.ts +++ b/packages/features/bookings/Booker/utils/event.ts @@ -59,27 +59,24 @@ export const useEvent = (props?: { fromRedirectOfNonOrgLink?: boolean; disabled? * this way the multi day view will show data of both months. */ export const useScheduleForEvent = ({ - prefetchNextMonth, username, eventSlug, eventId, month, duration, - monthCount, dayCount, selectedDate, orgSlug, teamMemberEmail, isTeamEvent, useApiV2 = true, + bookerLayout, }: { - prefetchNextMonth?: boolean; username?: string | null; eventSlug?: string | null; eventId?: number | null; month?: string | null; duration?: number | null; - monthCount?: number; dayCount?: number | null; selectedDate?: string | null; orgSlug?: string; @@ -87,7 +84,15 @@ export const useScheduleForEvent = ({ fromRedirectOfNonOrgLink?: boolean; isTeamEvent?: boolean; useApiV2?: boolean; -} = {}) => { + /** + * Required when prefetching is needed + */ + bookerLayout?: { + layout: string; + extraDays: number; + columnViewExtraDays: { current: number }; + }; +}) => { const { timezone } = useBookerTime(); const [usernameFromStore, eventSlugFromStore, monthFromStore, durationFromStore] = useBookerStoreContext( (state) => [state.username, state.eventSlug, state.month, state.selectedDuration], @@ -103,8 +108,6 @@ export const useScheduleForEvent = ({ eventId, timezone, selectedDate, - prefetchNextMonth, - monthCount, dayCount, rescheduleUid, month: monthFromStore ?? month, @@ -113,6 +116,7 @@ export const useScheduleForEvent = ({ orgSlug, teamMemberEmail, useApiV2: useApiV2, + bookerLayout, }); return { diff --git a/packages/features/schedules/lib/use-schedule/useSchedule.ts b/packages/features/schedules/lib/use-schedule/useSchedule.ts index 85370abb2a3f24..ef1958838d4c69 100644 --- a/packages/features/schedules/lib/use-schedule/useSchedule.ts +++ b/packages/features/schedules/lib/use-schedule/useSchedule.ts @@ -4,10 +4,10 @@ import { updateEmbedBookerState } from "@calcom/embed-core/src/embed-iframe"; import { sdkActionManager } from "@calcom/embed-core/src/sdk-event"; import { useBookerStore } from "@calcom/features/bookings/Booker/store"; import { isBookingDryRun } from "@calcom/features/bookings/Booker/utils/isBookingDryRun"; +import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents"; import { useTimesForSchedule } from "@calcom/features/schedules/lib/use-schedule/useTimesForSchedule"; import { getRoutedTeamMemberIdsFromSearchParams } from "@calcom/lib/bookings/getRoutedTeamMemberIdsFromSearchParams"; import { PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS } from "@calcom/lib/constants"; -import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents"; import { trpc } from "@calcom/trpc/react"; import { useApiV2AvailableSlots } from "./useApiV2AvailableSlots"; @@ -19,9 +19,7 @@ export type UseScheduleWithCacheArgs = { month?: string | null; timezone?: string | null; selectedDate?: string | null; - prefetchNextMonth?: boolean; duration?: number | null; - monthCount?: number | null; dayCount?: number | null; rescheduleUid?: string | null; isTeamEvent?: boolean; @@ -29,6 +27,14 @@ export type UseScheduleWithCacheArgs = { teamMemberEmail?: string | null; useApiV2?: boolean; enabled?: boolean; + /*** + * Required when prefetching is needed + */ + bookerLayout?: { + layout: string; + extraDays: number; + columnViewExtraDays: { current: number }; + }; }; const getAvailabilityLoadedEventPayload = ({ @@ -51,9 +57,7 @@ export const useSchedule = ({ eventSlug, eventId, selectedDate, - prefetchNextMonth, duration, - monthCount, dayCount, rescheduleUid, isTeamEvent, @@ -61,15 +65,15 @@ export const useSchedule = ({ teamMemberEmail, useApiV2 = false, enabled: enabledProp = true, + bookerLayout, }: UseScheduleWithCacheArgs) => { const bookerState = useBookerStore((state) => state.state); const [startTime, endTime] = useTimesForSchedule({ month, - monthCount, dayCount, - prefetchNextMonth, selectedDate, + bookerLayout, }); const searchParams = useSearchParams(); const routedTeamMemberIds = searchParams @@ -99,7 +103,9 @@ export const useSchedule = ({ startTime, // if `prefetchNextMonth` is true, two months are fetched at once. endTime, - timeZone: timezone!, + // We use a placeholder value that is there to keep TS happy, but still invalid to tell us that it shouldn't actually be passed in request(and wouldn't because enabled is false if timezone is nullish) + // TODO: Better approach here is to use `skipToken` from react-query which requires an upgrade of react-query + timeZone: timezone ?? "PLACEHOLDER_TIMEZONE", duration: duration ? `${duration}` : undefined, rescheduleUid, orgSlug, diff --git a/packages/features/schedules/lib/use-schedule/useTimesForSchedule.timezone.test.ts b/packages/features/schedules/lib/use-schedule/useTimesForSchedule.timezone.test.ts new file mode 100644 index 00000000000000..cf498a60cc0a55 --- /dev/null +++ b/packages/features/schedules/lib/use-schedule/useTimesForSchedule.timezone.test.ts @@ -0,0 +1,634 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import dayjs from "@calcom/dayjs"; +import type { BookerState } from "@calcom/features/bookings/Booker/types"; +import { BookerLayouts } from "@calcom/prisma/zod-utils"; + +import { useTimesForSchedule } from "./useTimesForSchedule"; + +// Mock the booker store context +const mockUseBookerStoreContext = vi.fn(); +vi.mock("@calcom/features/bookings/Booker/BookerStoreProvider", () => ({ + useBookerStoreContext: (selector: (state: { month: string | null; state: BookerState }) => unknown) => + mockUseBookerStoreContext(selector), +})); + +// Timezone offset mapping (in minutes from UTC) +const TIMEZONE_OFFSETS = { + "Asia/Kolkata": 330, // UTC+5:30 + "Asia/Calcutta": 330, // Legacy name for Asia/Kolkata + UTC: 0, // UTC+0 + "Etc/UTC": 0, // Alternative UTC + GMT: 0, // GMT is effectively UTC + "America/Los_Angeles": -480, // UTC-8 +} as const; + +// Helper to get current timezone from TZ environment variable +const getCurrentTimezone = (): string | undefined => { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + return timezone; +}; + +// Helper to check if current timezone is supported +const isTimezoneSupported = (): boolean => { + const currentTz = getCurrentTimezone(); + return currentTz ? currentTz in TIMEZONE_OFFSETS : false; +}; + +// Hardcoded expected timestamps for different timezones +const TIMEZONE_EXPECTATIONS = { + // January 1st start of month + JAN_START: { + UTC: "2024-01-01T00:00:00.000Z", + "Asia/Kolkata": "2023-12-31T18:30:00.000Z", + "Asia/Calcutta": "2023-12-31T18:30:00.000Z", + "America/Los_Angeles": "2024-01-01T08:00:00.000Z", + }, + // January 31st end of month + JAN_END: { + UTC: "2024-01-31T23:59:59.999Z", + "Asia/Kolkata": "2024-01-31T18:29:59.999Z", + "Asia/Calcutta": "2024-01-31T18:29:59.999Z", + "America/Los_Angeles": "2024-02-01T07:59:59.999Z", + }, + // February 1st start of month + FEB_START: { + UTC: "2024-02-01T00:00:00.000Z", + "Asia/Kolkata": "2024-01-31T18:30:00.000Z", + "Asia/Calcutta": "2024-01-31T18:30:00.000Z", + "America/Los_Angeles": "2024-02-01T08:00:00.000Z", + }, + // February 29th end of month (leap year) + FEB_END: { + UTC: "2024-02-29T23:59:59.999Z", + "Asia/Kolkata": "2024-02-29T18:29:59.999Z", + "Asia/Calcutta": "2024-02-29T18:29:59.999Z", + "America/Los_Angeles": "2024-03-01T07:59:59.999Z", + }, + // March 31st end of month + MAR_END: { + UTC: "2024-03-31T23:59:59.999Z", + "Asia/Kolkata": "2024-03-31T18:29:59.999Z", + "Asia/Calcutta": "2024-03-31T18:29:59.999Z", + "America/Los_Angeles": "2024-03-31T16:29:59.999Z", + }, + // January 15th start of day (for day count scenarios) + JAN_15_START: { + UTC: "2024-01-15T00:00:00.000Z", + "Asia/Kolkata": "2024-01-14T18:30:00.000Z", + "Asia/Calcutta": "2024-01-14T18:30:00.000Z", + "America/Los_Angeles": "2024-01-15T08:00:00.000Z", + }, + // January 18th start of day + JAN_18_START: { + UTC: "2024-01-18T00:00:00.000Z", + "Asia/Kolkata": "2024-01-17T18:30:00.000Z", + "Asia/Calcutta": "2024-01-17T18:30:00.000Z", + "America/Los_Angeles": "2024-01-18T08:00:00.000Z", + }, + // January 22nd start of day + JAN_22_START: { + UTC: "2024-01-22T00:00:00.000Z", + "Asia/Kolkata": "2024-01-21T18:30:00.000Z", + "Asia/Calcutta": "2024-01-21T18:30:00.000Z", + "America/Los_Angeles": "2024-01-22T08:00:00.000Z", + }, +} as const; + +// Helper to get expected timestamp for current timezone +const getExpectedTimestamp = (timestampKey: keyof typeof TIMEZONE_EXPECTATIONS): string => { + const currentTz = getCurrentTimezone(); + if (!currentTz || !(currentTz in TIMEZONE_OFFSETS)) { + throw new Error(`Unsupported timezone: ${currentTz}`); + } + + const expectations = TIMEZONE_EXPECTATIONS[timestampKey]; + return expectations[currentTz as keyof typeof expectations]; +}; + +// Test Data Builders +const createBookerLayout = ( + layout: string, + overrides?: { + extraDays?: number; + columnViewExtraDays?: number; + } +) => ({ + layout, + extraDays: overrides?.extraDays ?? 0, + columnViewExtraDays: { current: overrides?.columnViewExtraDays ?? 0 }, +}); + +const createWeekViewLayout = (overrides?: { extraDays?: number }) => + createBookerLayout(BookerLayouts.WEEK_VIEW, overrides); + +const createColumnViewLayout = (overrides?: { columnViewExtraDays?: number }) => + createBookerLayout(BookerLayouts.COLUMN_VIEW, overrides); + +const createMonthViewLayout = () => createBookerLayout(BookerLayouts.MONTH_VIEW); + +const createMobileViewLayout = () => createBookerLayout("mobile"); + +// Test Scenario Builders +const createTimeRangeScenario = (overrides?: { + selectedDate?: string | null; + month?: string | null; + bookerLayout?: ReturnType; + dayCount?: number | null; +}) => ({ + selectedDate: overrides?.selectedDate === undefined ? "2024-01-15" : overrides?.selectedDate, + month: overrides?.month ?? "2024-01", + bookerLayout: overrides?.bookerLayout ?? createMonthViewLayout(), + dayCount: overrides?.dayCount ?? null, +}); + +// Assertion Helpers +const expectValidTimeRange = (result: [string, string]) => { + const [startTime, endTime] = result; + expect(dayjs(startTime).isValid()).toBe(true); + expect(dayjs(endTime).isValid()).toBe(true); + expect(dayjs(endTime).isAfter(dayjs(startTime))).toBe(true); +}; + +// Setup mock store responses +const setupMockStore = ({ + monthFromStore = null, + bookerState = "loading", +}: { + monthFromStore?: string | null; + bookerState?: BookerState; +} = {}) => { + mockUseBookerStoreContext.mockImplementation((selector) => + selector({ month: monthFromStore, state: bookerState }) + ); +}; + +if (!isTimezoneSupported()) { + throw new Error(`Unsupported timezone: ${getCurrentTimezone()}`); +} +describe("useTimesForSchedule", () => { + beforeEach(() => { + vi.useFakeTimers(); + // Set consistent hardcoded system time for all tests + vi.setSystemTime(new Date("2024-01-15T10:00:00Z")); + setupMockStore(); // Default setup + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + describe("when using MONTH_VIEW layout", () => { + const testData = { + layout: createMonthViewLayout(), + bookerState: "selecting_time" as BookerState, + }; + + describe("and when current date is before mid-month", () => { + describe("and when viewing current month", () => { + it("with a specific selected date, should not prefetch and thus return time range of current month", () => { + vi.setSystemTime(new Date("2024-01-05T10:00:00Z")); + const currentMonth = "2024-01"; + setupMockStore({ monthFromStore: currentMonth, bookerState: testData.bookerState }); + + const scenario = createTimeRangeScenario({ + selectedDate: "2024-01-15", + month: currentMonth, + bookerLayout: testData.layout, + }); + + const result = useTimesForSchedule(scenario); + const [startTime, endTime] = result; + + expectValidTimeRange(result); + + expect(startTime).toBe(getExpectedTimestamp("JAN_START")); + expect(endTime).toBe(getExpectedTimestamp("JAN_END")); + }); + + it("without a specific selected date, should not prefetch and thus return time range of current month", () => { + vi.setSystemTime(new Date("2024-01-05T10:00:00Z")); + const currentMonth = "2024-01"; + setupMockStore({ monthFromStore: currentMonth, bookerState: testData.bookerState }); + + const scenario = createTimeRangeScenario({ + selectedDate: null, + month: currentMonth, + bookerLayout: testData.layout, + }); + + const result = useTimesForSchedule(scenario); + const [startTime, endTime] = result; + + expectValidTimeRange(result); + + expect(startTime).toBe(getExpectedTimestamp("JAN_START")); + expect(endTime).toBe(getExpectedTimestamp("JAN_END")); + }); + }); + }); + + describe("and when current date is after mid-month", () => { + describe("and when viewing current month", () => { + it("with a specific selected date, should return time range that prefetches the next month(thus total 2 months) ", () => { + const currentMonth = "2024-01"; + vi.setSystemTime(new Date("2024-01-20T10:00:00Z")); + + setupMockStore({ monthFromStore: currentMonth, bookerState: testData.bookerState }); + + const scenario = createTimeRangeScenario({ + selectedDate: "2024-01-22", + month: currentMonth, + bookerLayout: testData.layout, + }); + + const result = useTimesForSchedule(scenario); + const [startTime, endTime] = result; + + expectValidTimeRange(result); + + expect(startTime).toBe(getExpectedTimestamp("JAN_START")); + expect(endTime).toBe(getExpectedTimestamp("FEB_END")); + }); + + it("without a specific selected date, should return time range that prefetches the next month(thus total 2 months) ", () => { + const currentMonth = "2024-01"; + vi.setSystemTime(new Date("2024-01-20T10:00:00Z")); + + setupMockStore({ monthFromStore: currentMonth, bookerState: testData.bookerState }); + + const scenario = createTimeRangeScenario({ + selectedDate: null, + month: currentMonth, + bookerLayout: testData.layout, + }); + + const result = useTimesForSchedule(scenario); + const [startTime, endTime] = result; + + expectValidTimeRange(result); + // Past mid-month, so prefetch to March + expect(startTime).toBe(getExpectedTimestamp("JAN_START")); + expect(endTime).toBe(getExpectedTimestamp("FEB_END")); // 2024 is leap year + }); + }); + }); + + it.skip("and in loading state, should not prefetch and thus return time range of current month even when column view extra days are present(which keeps the date in the current month)", () => { + setupMockStore({ monthFromStore: "2024-01", bookerState: "loading" }); + + const scenario = createTimeRangeScenario({ + selectedDate: "2024-01-01", + bookerLayout: createBookerLayout(BookerLayouts.MONTH_VIEW, { columnViewExtraDays: 5 }), + }); + + const [startTime, endTime] = useTimesForSchedule(scenario); + + expectValidTimeRange([startTime, endTime]); + expect(startTime).toBe(getExpectedTimestamp("JAN_START")); + // The test is failing currently here for endTime as endTime is coming out to be FEB_END + expect(endTime).toBe(getExpectedTimestamp("JAN_END")); + }); + + it("should return time range of the next month(no prefetch) when user is viewing next month", () => { + // Set time to next month (February) while viewing January + const nextMonth = "2024-02"; + setupMockStore({ monthFromStore: nextMonth, bookerState: testData.bookerState }); + + const scenario = createTimeRangeScenario({ + selectedDate: "2024-02-15", + month: nextMonth, + bookerLayout: testData.layout, + }); + + const result = useTimesForSchedule(scenario); + const [startTime, endTime] = result; + + expectValidTimeRange(result); + // Viewing different month, so no prefetch + expect(startTime).toBe(getExpectedTimestamp("FEB_START")); + expect(endTime).toBe(getExpectedTimestamp("FEB_END")); + }); + }); + + describe("when using WEEK_VIEW layout", () => { + const testData = { + layout: createWeekViewLayout, + bookerState: "selecting_time" as BookerState, + }; + + describe("and when current date is before mid-month", () => { + it("should not prefetch and thus return time range of current month when extra days remain within current month", () => { + const currentMonth = "2024-01"; + setupMockStore({ monthFromStore: currentMonth, bookerState: testData.bookerState }); + + const scenario = createTimeRangeScenario({ + selectedDate: "2024-01-05", + month: currentMonth, + bookerLayout: testData.layout({ extraDays: 5 }), + }); + + const result = useTimesForSchedule(scenario); + const [startTime, endTime] = result; + + expectValidTimeRange(result); + // Should stay within January (no prefetch triggered) + expect(startTime).toBe(getExpectedTimestamp("JAN_START")); + expect(endTime).toBe(getExpectedTimestamp("JAN_END")); + }); + + it("should not prefetch and thus return time range of current month when week view has no extra days configured", () => { + const currentMonth = "2024-01"; + setupMockStore({ monthFromStore: currentMonth, bookerState: testData.bookerState }); + + const scenario = createTimeRangeScenario({ + selectedDate: "2024-01-05", + month: currentMonth, + bookerLayout: testData.layout({ extraDays: 0 }), + }); + + const result = useTimesForSchedule(scenario); + const [startTime, endTime] = result; + + expectValidTimeRange(result); + // Should stay within January (no extra days, no prefetch) + expect(startTime).toBe(getExpectedTimestamp("JAN_START")); + expect(endTime).toBe(getExpectedTimestamp("JAN_END")); + }); + }); + + describe("and when current date is after mid-month", () => { + it("with extra days that push date into following month, should prefetch and thus return time range that prefetches the next month(thus total 2 months)", () => { + const currentMonth = "2024-01"; + setupMockStore({ monthFromStore: currentMonth, bookerState: testData.bookerState }); + + const scenario = createTimeRangeScenario({ + selectedDate: "2024-01-27", + month: currentMonth, + bookerLayout: testData.layout({ extraDays: 5 }), + }); + + const result = useTimesForSchedule(scenario); + const [startTime, endTime] = result; + + expectValidTimeRange(result); + + // Should span from start of Jan to end of Feb (prefetch triggered) + expect(startTime).toBe(getExpectedTimestamp("JAN_START")); + expect(endTime).toBe(getExpectedTimestamp("FEB_END")); // 2024 is leap year + }); + + it("without a specific selected date, should not prefetch and thus return time range of current month", () => { + const currentMonth = "2024-01"; + vi.setSystemTime(new Date("2024-01-20T10:00:00Z")); + + setupMockStore({ monthFromStore: currentMonth, bookerState: testData.bookerState }); + + const scenario = createTimeRangeScenario({ + selectedDate: null, + month: currentMonth, + bookerLayout: testData.layout(), + }); + + const result = useTimesForSchedule(scenario); + const [startTime, endTime] = result; + + expectValidTimeRange(result); + expect(startTime).toBe(getExpectedTimestamp("JAN_START")); + expect(endTime).toBe(getExpectedTimestamp("JAN_END")); + }); + }); + }); + + describe("when using COLUMN_VIEW layout", () => { + const testData = { + layout: createColumnViewLayout, + bookerState: "selecting_time" as BookerState, + }; + + describe("and when current date is before mid-month", () => { + it("should not prefetch and thus return time range of current month when column view extra days remain within current month", () => { + const currentMonth = "2024-01"; + setupMockStore({ monthFromStore: currentMonth, bookerState: testData.bookerState }); + const scenario = createTimeRangeScenario({ + selectedDate: "2024-01-05", + month: currentMonth, + bookerLayout: testData.layout({ columnViewExtraDays: 5 }), + }); + + const result = useTimesForSchedule(scenario); + const [startTime, endTime] = result; + + expectValidTimeRange(result); + expect(startTime).toBe(getExpectedTimestamp("JAN_START")); + expect(endTime).toBe(getExpectedTimestamp("JAN_END")); + }); + }); + + describe("and when current date is after mid-month", () => { + it("with extra days that push date into following month, should prefetch and thus return time range that prefetches the next month(thus total 2 months)", () => { + const currentMonth = "2024-01"; + setupMockStore({ monthFromStore: currentMonth, bookerState: testData.bookerState }); + + const scenario = createTimeRangeScenario({ + selectedDate: "2024-01-28", + month: currentMonth, + bookerLayout: testData.layout({ columnViewExtraDays: 5 }), + }); + + const result = useTimesForSchedule(scenario); + const [startTime, endTime] = result; + + expectValidTimeRange(result); + // Should span from start of Jan to end of Feb (prefetch triggered) + expect(startTime).toBe(getExpectedTimestamp("JAN_START")); + expect(endTime).toBe(getExpectedTimestamp("FEB_END")); + }); + + it("without a specific selected date, should not prefetch and thus return time range of current month", () => { + const currentMonth = "2024-01"; + vi.setSystemTime(new Date("2024-01-20T10:00:00Z")); + + setupMockStore({ monthFromStore: currentMonth, bookerState: testData.bookerState }); + + const scenario = createTimeRangeScenario({ + selectedDate: null, + month: currentMonth, + bookerLayout: testData.layout(), + }); + + const result = useTimesForSchedule(scenario); + const [startTime, endTime] = result; + + expectValidTimeRange(result); + expect(startTime).toBe(getExpectedTimestamp("JAN_START")); + expect(endTime).toBe(getExpectedTimestamp("JAN_END")); + }); + }); + + it("and in loading state, should not prefetch and thus return time range of current month even when column view extra days are present(which keeps the date in the current month)", () => { + setupMockStore({ monthFromStore: "2024-01", bookerState: "loading" }); + + const scenario = createTimeRangeScenario({ + selectedDate: "2024-01-01", + bookerLayout: createBookerLayout(BookerLayouts.COLUMN_VIEW, { columnViewExtraDays: 5 }), + }); + + const [startTime, endTime] = useTimesForSchedule(scenario); + + expectValidTimeRange([startTime, endTime]); + expect(startTime).toBe(getExpectedTimestamp("JAN_START")); + // The test is failing currently here for endTime as endTime is coming out to be FEB_END + expect(endTime).toBe(getExpectedTimestamp("JAN_END")); + }); + }); + + describe("when using mobile layout", () => { + const testData = { + layout: createMobileViewLayout, + bookerState: "selecting_time" as BookerState, + }; + + describe("and when current date is after mid-month", () => { + it("with a specific selected date, should return time range that prefetches the next month(thus total 2 months) ", () => { + const currentMonth = "2024-01"; + vi.setSystemTime(new Date("2024-01-20T10:00:00Z")); + + setupMockStore({ monthFromStore: currentMonth, bookerState: testData.bookerState }); + + const scenario = createTimeRangeScenario({ + selectedDate: "2024-01-22", + month: currentMonth, + bookerLayout: testData.layout(), + }); + + const result = useTimesForSchedule(scenario); + const [startTime, endTime] = result; + + expectValidTimeRange(result); + + expect(startTime).toBe(getExpectedTimestamp("JAN_START")); + expect(endTime).toBe(getExpectedTimestamp("FEB_END")); + }); + + it("without a specific selected date, should return time range that prefetches the next month(thus total 2 months) ", () => { + const currentMonth = "2024-01"; + vi.setSystemTime(new Date("2024-01-20T10:00:00Z")); + + setupMockStore({ monthFromStore: currentMonth, bookerState: testData.bookerState }); + + const scenario = createTimeRangeScenario({ + selectedDate: null, + month: currentMonth, + bookerLayout: testData.layout(), + }); + + const result = useTimesForSchedule(scenario); + const [startTime, endTime] = result; + + expectValidTimeRange(result); + // Past mid-month, so prefetch to March + expect(startTime).toBe(getExpectedTimestamp("JAN_START")); + expect(endTime).toBe(getExpectedTimestamp("FEB_END")); // 2024 is leap year + }); + }); + + it.skip("and in loading state, should not prefetch and thus return time range of current month even when column view extra days are present(which keeps the date in the current month)", () => { + setupMockStore({ monthFromStore: "2024-01", bookerState: "loading" }); + + const scenario = createTimeRangeScenario({ + selectedDate: "2024-01-01", + bookerLayout: createBookerLayout("mobile", { columnViewExtraDays: 5 }), + }); + + const [startTime, endTime] = useTimesForSchedule(scenario); + + expectValidTimeRange([startTime, endTime]); + expect(startTime).toBe(getExpectedTimestamp("JAN_START")); + // The test is failing currently here for endTime as endTime is coming out to be FEB_END + expect(endTime).toBe(getExpectedTimestamp("JAN_END")); + }); + }); + + describe("when handling day count scenarios", () => { + it("should return specific day range when dayCount is provided", () => { + setupMockStore({ monthFromStore: "2024-01", bookerState: "selecting_time" }); + + const scenario = createTimeRangeScenario({ + selectedDate: "2024-01-15", + dayCount: 7, // 7 days + bookerLayout: createMonthViewLayout(), + }); + + const result = useTimesForSchedule(scenario); + const [startTime, endTime] = result; + + expectValidTimeRange(result); + // Should span exactly 7 days from selectedDate + expect(startTime).toBe(getExpectedTimestamp("JAN_15_START")); + expect(endTime).toBe(getExpectedTimestamp("JAN_22_START")); + }); + + it("should return current day + dayCount when no selectedDate provided and current month", () => { + const testMonth = "2024-01"; + setupMockStore({ monthFromStore: testMonth, bookerState: "selecting_time" }); + + const scenario = createTimeRangeScenario({ + selectedDate: null as unknown as string, + dayCount: 3, + month: testMonth, + bookerLayout: createMonthViewLayout(), + }); + + const result = useTimesForSchedule(scenario); + const [startTime, endTime] = result; + + expectValidTimeRange(result); + // Should start from current system time (2024-01-15) + 3 days + expect(startTime).toBe(getExpectedTimestamp("JAN_15_START")); + expect(endTime).toBe(getExpectedTimestamp("JAN_18_START")); + }); + }); + + describe("when store month differs from prop month", () => { + it("should prioritize store month over prop month", () => { + const storeMonth = "2024-02"; + const propMonth = "2024-01"; + setupMockStore({ monthFromStore: storeMonth, bookerState: "selecting_time" }); + + const scenario = createTimeRangeScenario({ + selectedDate: "2024-01-15", + month: propMonth, // This should be overridden by store month + bookerLayout: createMonthViewLayout(), + }); + + const result = useTimesForSchedule(scenario); + + expectValidTimeRange(result); + + // Start time should be based on store month (February), not prop month (January) + const [startTime] = result; + expect(dayjs(startTime).format("YYYY-MM")).toBe(storeMonth); + }); + + it("should use prop month when store month is null", () => { + const propMonth = "2024-01"; + setupMockStore({ monthFromStore: null, bookerState: "selecting_time" }); // Store month is null + + const scenario = createTimeRangeScenario({ + selectedDate: "2024-01-15", + month: propMonth, + bookerLayout: createMonthViewLayout(), + }); + + const result = useTimesForSchedule(scenario); + + expectValidTimeRange(result); + + // Should fall back to prop month + const [startTime] = result; + expect(dayjs(startTime).format("YYYY-MM")).toBe(propMonth); + }); + }); +}); diff --git a/packages/features/schedules/lib/use-schedule/useTimesForSchedule.ts b/packages/features/schedules/lib/use-schedule/useTimesForSchedule.ts index eeb62d6d8ed6ee..243c1e5891d18c 100644 --- a/packages/features/schedules/lib/use-schedule/useTimesForSchedule.ts +++ b/packages/features/schedules/lib/use-schedule/useTimesForSchedule.ts @@ -1,24 +1,85 @@ +import { shallow } from "zustand/shallow"; + import dayjs from "@calcom/dayjs"; +import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider"; +import type { BookerState } from "@calcom/features/bookings/Booker/types"; +import { getPrefetchMonthCount } from "@calcom/features/bookings/Booker/utils/getPrefetchMonthCount"; +import { isPrefetchNextMonthEnabled } from "@calcom/features/bookings/Booker/utils/isPrefetchNextMonthEnabled"; import type { UseScheduleWithCacheArgs } from "./useSchedule"; type UseTimesForScheduleProps = Pick< UseScheduleWithCacheArgs, - "month" | "monthCount" | "dayCount" | "selectedDate" | "prefetchNextMonth" + "month" | "dayCount" | "selectedDate" | "bookerLayout" >; + +interface UsePrefetchParams { + date: string; + month: string | null; + bookerLayout?: { + layout: string; + extraDays: number; + columnViewExtraDays: { current: number }; + }; + bookerState: BookerState; +} + +const usePrefetch = ({ date, month, bookerLayout, bookerState }: UsePrefetchParams) => { + if (!bookerLayout) { + return null; + } + + const dateMonth = dayjs(date).month(); + const monthAfterAdding1Month = dayjs(date).add(1, "month").month(); + const monthAfterAddingExtraDays = dayjs(date).add(bookerLayout.extraDays, "day").month(); + const monthAfterAddingExtraDaysColumnView = dayjs(date) + .add(bookerLayout.columnViewExtraDays.current, "day") + .month(); + + const prefetchNextMonth = isPrefetchNextMonthEnabled( + bookerLayout.layout, + date, + dateMonth, + monthAfterAddingExtraDays, + monthAfterAddingExtraDaysColumnView, + month, + bookerLayout.extraDays + ); + + if (!prefetchNextMonth) { + return null; + } + + const monthCount = getPrefetchMonthCount( + bookerLayout.layout, + bookerState, + monthAfterAdding1Month, + monthAfterAddingExtraDaysColumnView, + prefetchNextMonth + ); + + return { monthsToPrefetch: monthCount ?? 1 }; +}; + export const useTimesForSchedule = ({ - month, - monthCount, + month: monthFromProps, selectedDate, dayCount, - prefetchNextMonth, + bookerLayout, }: UseTimesForScheduleProps): [string, string] => { + const [monthFromStore, bookerState] = useBookerStoreContext((state) => [state.month, state.state], shallow); + const month = monthFromStore ?? monthFromProps ?? null; + const date = dayjs(selectedDate).format("YYYY-MM-DD"); + const prefetchData = usePrefetch({ + date, + month, + bookerLayout, + bookerState, + }); + const now = dayjs(); const monthDayjs = month ? dayjs(month) : now; - const nextMonthDayjs = monthDayjs.add(monthCount ? monthCount : 1, "month"); - // Why the non-null assertions? All of these arguments are checked in the enabled condition, - // and the query will not run if they are null. However, the check in `enabled` does - // no satisfy typescript. + let startTime; let endTime; @@ -34,8 +95,10 @@ export const useTimesForSchedule = ({ endTime = monthDayjs.startOf("month").add(dayCount, "day").toISOString(); } } else { + const monthsToPrefetch = prefetchData?.monthsToPrefetch; + const lastMonthToPrefetchDayjs = monthsToPrefetch ? monthDayjs.add(monthsToPrefetch, "month") : null; startTime = monthDayjs.startOf("month").toISOString(); - endTime = (prefetchNextMonth ? nextMonthDayjs : monthDayjs).endOf("month").toISOString(); + endTime = (lastMonthToPrefetchDayjs ? lastMonthToPrefetchDayjs : monthDayjs).endOf("month").toISOString(); } return [startTime, endTime]; }; diff --git a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx index a537d2c99ba536..25e9e8bb281653 100644 --- a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx +++ b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx @@ -15,7 +15,6 @@ import { import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout"; import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm"; import { useLocalSet } from "@calcom/features/bookings/Booker/components/hooks/useLocalSet"; -import { usePrefetch } from "@calcom/features/bookings/Booker/components/hooks/usePrefetch"; import { useInitializeBookerStore } from "@calcom/features/bookings/Booker/store"; import { useTimePreferences } from "@calcom/features/bookings/lib"; import type { ConnectedDestinationCalendars } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars"; @@ -77,7 +76,7 @@ const BookerPlatformWrapperComponent = ( const { clientId } = useAtomsContext(); const teamId: number | undefined = props.isTeamEvent ? props.teamId : undefined; - const [bookerState, setBookerState] = useBookerStoreContext( + const [_bookerState, setBookerState] = useBookerStoreContext( (state) => [state.state, state.setState], shallow ); @@ -237,22 +236,14 @@ const BookerPlatformWrapperComponent = ( const extraOptions = useMemo(() => { return restFormValues; }, [restFormValues]); - const date = dayjs(selectedDate).format("YYYY-MM-DD"); - const { prefetchNextMonth, monthCount } = usePrefetch({ - date, - month, - bookerLayout, - bookerState, - }); const { timezone } = useTimePreferences(); const [calculatedStartTime, calculatedEndTime] = useTimesForSchedule({ month, - monthCount, dayCount, - prefetchNextMonth, selectedDate, + bookerLayout, }); const startTime = diff --git a/packages/platform/atoms/booker/BookerWebWrapper.tsx b/packages/platform/atoms/booker/BookerWebWrapper.tsx index 1084f404ae23df..1ec2bcc1147b19 100644 --- a/packages/platform/atoms/booker/BookerWebWrapper.tsx +++ b/packages/platform/atoms/booker/BookerWebWrapper.tsx @@ -6,7 +6,6 @@ import { useMemo, useCallback, useEffect, useRef } from "react"; import React from "react"; import { shallow } from "zustand/shallow"; -import dayjs from "@calcom/dayjs"; import { sdkActionManager, useIsEmbed, @@ -23,7 +22,6 @@ import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hoo import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm"; import { useBookings } from "@calcom/features/bookings/Booker/components/hooks/useBookings"; import { useCalendars } from "@calcom/features/bookings/Booker/components/hooks/useCalendars"; -import { usePrefetch } from "@calcom/features/bookings/Booker/components/hooks/usePrefetch"; import { useSlots } from "@calcom/features/bookings/Booker/components/hooks/useSlots"; import { useVerifyCode } from "@calcom/features/bookings/Booker/components/hooks/useVerifyCode"; import { useVerifyEmail } from "@calcom/features/bookings/Booker/components/hooks/useVerifyEmail"; @@ -39,8 +37,7 @@ export type BookerWebWrapperAtomProps = BookerProps & { eventData?: NonNullable>>; }; - -const BookerPlatformWrapperComponent = (props: BookerWebWrapperAtomProps) => { +const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -67,7 +64,6 @@ const BookerPlatformWrapperComponent = (props: BookerWebWrapperAtomProps) => { typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("rescheduledBy") : null; const bookingUid = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("bookingUid") : null; - const date = dayjs(selectedDate).format("YYYY-MM-DD"); const timezone = searchParams?.get("cal.tz") || null; useEffect(() => { @@ -95,9 +91,7 @@ const BookerPlatformWrapperComponent = (props: BookerWebWrapperAtomProps) => { timezone, }); - const [bookerState, _] = useBookerStoreContext((state) => [state.state, state.setState], shallow); const [dayCount] = useBookerStoreContext((state) => [state.dayCount, state.setDayCount], shallow); - const [month] = useBookerStoreContext((state) => [state.month, state.setMonth], shallow); const { data: session } = useSession(); const routerQuery = useRouterQuery(); @@ -142,21 +136,13 @@ const BookerPlatformWrapperComponent = (props: BookerWebWrapperAtomProps) => { const isEmbed = useIsEmbed(); - const { prefetchNextMonth, monthCount } = usePrefetch({ - date, - month, - bookerLayout, - bookerState, - }); /** * Prioritize dateSchedule load * Component will render but use data already fetched from here, and no duplicate requests will be made * */ const schedule = useScheduleForEvent({ - prefetchNextMonth, eventId: props.entity.eventTypeId ?? event.data?.id, username: props.username, - monthCount, dayCount, eventSlug: props.eventSlug, month: props.month, @@ -166,6 +152,7 @@ const BookerPlatformWrapperComponent = (props: BookerWebWrapperAtomProps) => { fromRedirectOfNonOrgLink: props.entity.fromRedirectOfNonOrgLink, isTeamEvent: props.isTeamEvent ?? !!event.data?.team, useApiV2: props.useApiV2, + bookerLayout, ...(props.entity.orgSlug ? { orgSlug: props.entity.orgSlug } : {}), }); const bookings = useBookings({ @@ -280,7 +267,7 @@ const BookerPlatformWrapperComponent = (props: BookerWebWrapperAtomProps) => { export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => { return ( - + ); }; diff --git a/packages/platform/atoms/calendar-view/EventTypeCalendarViewComponent.tsx b/packages/platform/atoms/calendar-view/EventTypeCalendarViewComponent.tsx index fc639eb3bcf745..51203f4af930f3 100644 --- a/packages/platform/atoms/calendar-view/EventTypeCalendarViewComponent.tsx +++ b/packages/platform/atoms/calendar-view/EventTypeCalendarViewComponent.tsx @@ -1,7 +1,6 @@ import { useMemo } from "react"; import { shallow } from "zustand/shallow"; -import dayjs from "@calcom/dayjs"; import { useBookerStoreContext, useInitializeBookerStoreContext, @@ -10,7 +9,6 @@ import { Header } from "@calcom/features/bookings/Booker/components/Header"; import { BookerSection } from "@calcom/features/bookings/Booker/components/Section"; import { useAvailableTimeSlots } from "@calcom/features/bookings/Booker/components/hooks/useAvailableTimeSlots"; import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout"; -import { usePrefetch } from "@calcom/features/bookings/Booker/components/hooks/usePrefetch"; import { useTimePreferences } from "@calcom/features/bookings/lib"; import { LargeCalendar } from "@calcom/features/calendar-view/LargeCalendar"; import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents"; @@ -59,28 +57,15 @@ export const EventTypeCalendarViewComponent = ( const bookerLayout = useBookerLayout(event.data?.profile?.bookerLayouts); - const [bookerState, _setBookerState] = useBookerStoreContext( - (state) => [state.state, state.setState], - shallow - ); const selectedDate = useBookerStoreContext((state) => state.selectedDate); - const date = dayjs(selectedDate).format("YYYY-MM-DD"); const month = useBookerStoreContext((state) => state.month); const [dayCount] = useBookerStoreContext((state) => [state.dayCount, state.setDayCount], shallow); - const { prefetchNextMonth, monthCount } = usePrefetch({ - date, - month, - bookerLayout, - bookerState, - }); - const [startTime, endTime] = useTimesForSchedule({ month, - monthCount, dayCount, - prefetchNextMonth, selectedDate, + bookerLayout, }); const { timezone } = useTimePreferences();