From 4c73695d3ae3dd4dbb0b364aaf406a94fa65d699 Mon Sep 17 00:00:00 2001 From: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:46:40 +0400 Subject: [PATCH] fix: refresh slots on timezone change for booker timezone restrictions (#27491) * fix: refresh slots on timezone change for booker timezone restrictions * refactor: use useMemo for timezone change detection * revert: remove unnecessary formatting changes * feat: add timezone refresh for platform components Add timezone change detection and slot refresh to BookerPlatformWrapper and EventTypeCalendarViewComponent to handle restriction schedules with useBookerTimezone enabled. * refactor: extract timezone slot refresh logic into reusable hook * Update packages/platform/atoms/calendar-view/EventTypeCalendarViewComponent.tsx Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> * Update packages/platform/atoms/calendar-view/EventTypeCalendarViewComponent.tsx Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> * fix * fix: prevent unnecessary getSchedule calls when useBookerTimezone is disabled * fix: add timezone fields to BookerEvent type * fix: add missing properties to BookerEvent and BookerEventProfile types * trying to fix type errors * Add restrictionScheduleId and useBookerTimezone fields * fix: correct import path for useBookerTime hook Co-Authored-By: ali@cal.com * fix: explicitly include restrictionScheduleId and useBookerTimezone in getPublicEvent return Co-Authored-By: ali@cal.com * chore: trigger fresh CI build Co-Authored-By: ali@cal.com * fix: cast event.data to BookerEvent for timezone fields access Co-Authored-By: ali@cal.com * refactor: address review feedback for timezone slot refresh Co-Authored-By: ali@cal.com * refactor: add explicit return type to event handler to ensure type propagation Co-Authored-By: ali@cal.com * refactor: extract useStableTimezone hook and remove dead timezone detection code Co-Authored-By: ali@cal.com --------- Co-authored-by: hackice20 Co-authored-by: Yash <116657771+hackice20@users.noreply.github.com> Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../bookings/components/BookerWebWrapper.tsx | 15 ++-- apps/web/modules/schedules/hooks/useEvent.ts | 9 ++- .../Booker/hooks/useStableTimezone.test.ts | 74 +++++++++++++++++++ .../Booker/hooks/useStableTimezone.ts | 13 ++++ packages/features/bookings/types.ts | 14 ++-- .../features/eventtypes/lib/getPublicEvent.ts | 6 ++ .../atoms/booker/BookerPlatformWrapper.tsx | 17 +++-- .../EventTypeCalendarViewComponent.tsx | 11 ++- .../routers/publicViewer/event.handler.ts | 3 +- 9 files changed, 140 insertions(+), 22 deletions(-) create mode 100644 packages/features/bookings/Booker/hooks/useStableTimezone.test.ts create mode 100644 packages/features/bookings/Booker/hooks/useStableTimezone.ts diff --git a/apps/web/modules/bookings/components/BookerWebWrapper.tsx b/apps/web/modules/bookings/components/BookerWebWrapper.tsx index 009de9fc35..a252fcb0b4 100644 --- a/apps/web/modules/bookings/components/BookerWebWrapper.tsx +++ b/apps/web/modules/bookings/components/BookerWebWrapper.tsx @@ -19,7 +19,7 @@ import { useInitializeBookerStore } from "@calcom/features/bookings/Booker/store import { useEvent, useScheduleForEvent } from "@calcom/web/modules/schedules/hooks/useEvent"; import { useBrandColors } from "@calcom/features/bookings/Booker/utils/use-brand-colors"; import type { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent"; -import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR, WEBAPP_URL } from "@calcom/lib/constants"; +import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR, WEBAPP_URL } from "@calcom/lib/constants"; import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; import { localStorage } from "@calcom/lib/webstorage"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; @@ -42,11 +42,11 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps): JSX.Elemen }); const event = props.eventData ? { - data: props.eventData, - isSuccess: true, - isError: false, - isPending: false, - } + data: props.eventData, + isSuccess: true, + isError: false, + isPending: false, + } : clientFetchedEvent; const bookerLayout = useBookerLayout(event.data?.profile?.bookerLayouts); @@ -149,6 +149,9 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps): JSX.Elemen useApiV2: props.useApiV2, bookerLayout, ...(props.entity.orgSlug ? { orgSlug: props.entity.orgSlug } : {}), + restrictionSchedule: event.data?.restrictionScheduleId + ? { id: event.data.restrictionScheduleId, useBookerTimezone: event.data.useBookerTimezone } + : undefined, }); const bookings = useBookings({ event, diff --git a/apps/web/modules/schedules/hooks/useEvent.ts b/apps/web/modules/schedules/hooks/useEvent.ts index b91671eded..46b25d527b 100644 --- a/apps/web/modules/schedules/hooks/useEvent.ts +++ b/apps/web/modules/schedules/hooks/useEvent.ts @@ -6,6 +6,7 @@ import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { trpc } from "@calcom/trpc/react"; import { useBookerTime } from "@calcom/features/bookings/Booker/hooks/useBookerTime"; +import { useStableTimezone } from "@calcom/features/bookings/Booker/hooks/useStableTimezone"; export type useEventReturnType = ReturnType; export type useScheduleForEventReturnType = ReturnType; @@ -71,6 +72,7 @@ export const useScheduleForEvent = ({ isTeamEvent, useApiV2 = true, bookerLayout, + restrictionSchedule, }: { username?: string | null; eventSlug?: string | null; @@ -92,13 +94,16 @@ export const useScheduleForEvent = ({ extraDays: number; columnViewExtraDays: { current: number }; }; + restrictionSchedule?: { id: number | null; useBookerTimezone: boolean }; }) => { - const { timezone } = useBookerTime(); + const { timezone: rawTimezone } = useBookerTime(); const [usernameFromStore, eventSlugFromStore, monthFromStore, durationFromStore] = useBookerStoreContext( (state) => [state.username, state.eventSlug, state.month, state.selectedDuration], shallow ); + const effectiveTimezone = useStableTimezone(rawTimezone, restrictionSchedule); + const searchParams = useCompatSearchParams(); const rescheduleUid = searchParams?.get("rescheduleUid"); @@ -106,7 +111,7 @@ export const useScheduleForEvent = ({ username: usernameFromStore ?? username, eventSlug: eventSlugFromStore ?? eventSlug, eventId, - timezone, + timezone: effectiveTimezone, selectedDate, dayCount, rescheduleUid, diff --git a/packages/features/bookings/Booker/hooks/useStableTimezone.test.ts b/packages/features/bookings/Booker/hooks/useStableTimezone.test.ts new file mode 100644 index 0000000000..f925320559 --- /dev/null +++ b/packages/features/bookings/Booker/hooks/useStableTimezone.test.ts @@ -0,0 +1,74 @@ +/** + * @vitest-environment jsdom + */ +import { renderHook } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; + +import { useStableTimezone } from "./useStableTimezone"; + +describe("useStableTimezone", () => { + it("returns the raw timezone when no restriction schedule is provided", () => { + const { result } = renderHook(() => useStableTimezone("America/New_York")); + expect(result.current).toBe("America/New_York"); + }); + + it("returns the raw timezone when restriction schedule is undefined", () => { + const { result } = renderHook(() => useStableTimezone("America/New_York", undefined)); + expect(result.current).toBe("America/New_York"); + }); + + it("returns the raw timezone when restriction schedule id is null", () => { + const { result } = renderHook(() => + useStableTimezone("America/New_York", { id: null, useBookerTimezone: false }) + ); + expect(result.current).toBe("America/New_York"); + }); + + it("returns the raw timezone when restriction schedule id is 0", () => { + const { result } = renderHook(() => + useStableTimezone("America/New_York", { id: 0, useBookerTimezone: false }) + ); + expect(result.current).toBe("America/New_York"); + }); + + it("returns the raw timezone when useBookerTimezone is true (timezone should follow the booker)", () => { + const { result } = renderHook(() => + useStableTimezone("America/New_York", { id: 1, useBookerTimezone: true }) + ); + expect(result.current).toBe("America/New_York"); + }); + + it("pins to initial timezone when restriction schedule exists and useBookerTimezone is false", () => { + let timezone = "America/New_York"; + const { result, rerender } = renderHook(() => + useStableTimezone(timezone, { id: 1, useBookerTimezone: false }) + ); + expect(result.current).toBe("America/New_York"); + + timezone = "Europe/London"; + rerender(); + expect(result.current).toBe("America/New_York"); + }); + + it("follows timezone changes when useBookerTimezone is true", () => { + let timezone = "America/New_York"; + const { result, rerender } = renderHook(() => + useStableTimezone(timezone, { id: 1, useBookerTimezone: true }) + ); + expect(result.current).toBe("America/New_York"); + + timezone = "Europe/London"; + rerender(); + expect(result.current).toBe("Europe/London"); + }); + + it("follows timezone changes when there is no restriction schedule", () => { + let timezone = "America/New_York"; + const { result, rerender } = renderHook(() => useStableTimezone(timezone)); + expect(result.current).toBe("America/New_York"); + + timezone = "Europe/London"; + rerender(); + expect(result.current).toBe("Europe/London"); + }); +}); diff --git a/packages/features/bookings/Booker/hooks/useStableTimezone.ts b/packages/features/bookings/Booker/hooks/useStableTimezone.ts new file mode 100644 index 0000000000..43a75c055f --- /dev/null +++ b/packages/features/bookings/Booker/hooks/useStableTimezone.ts @@ -0,0 +1,13 @@ +import { useRef } from "react"; + +export function useStableTimezone( + timezone: string, + restrictionSchedule?: { id: number | null; useBookerTimezone: boolean } +): string { + const initialRef = useRef(timezone); + const shouldPin = + restrictionSchedule?.id != null && + restrictionSchedule.id > 0 && + restrictionSchedule.useBookerTimezone === false; + return shouldPin ? initialRef.current : timezone; +} diff --git a/packages/features/bookings/types.ts b/packages/features/bookings/types.ts index 3eb865780c..e6a73c7825 100644 --- a/packages/features/bookings/types.ts +++ b/packages/features/bookings/types.ts @@ -1,5 +1,3 @@ -import type { ErrorOption, FieldPath } from "react-hook-form"; - import type { RegularBookingCreateResult } from "@calcom/features/bookings/lib/dto/types"; import type { Slots } from "@calcom/features/calendars/lib/types"; import type { PublicEventType } from "@calcom/features/eventtypes/lib/getPublicEvent"; @@ -26,7 +24,10 @@ type BookerEventUser = Pick< bookerUrl: string; }; -type BookerEventProfile = Pick; +type BookerEventProfile = Pick< + PublicEvent["profile"], + "name" | "image" | "bookerLayouts" | "brandColor" | "darkBrandColor" | "theme" | "weekStart" | "username" +>; // Re-export Slots from the server-safe location export type { Slots }; @@ -40,10 +41,10 @@ export type BookerEvent = Pick< | "recurringEvent" | "entity" | "locations" - | "enablePerHostLocations" | "metadata" | "isDynamic" | "requiresConfirmation" + | "requiresBookerEmailVerification" | "price" | "currency" | "lockTimeZoneToggleOnBookingPage" @@ -66,13 +67,14 @@ export type BookerEvent = Pick< | "interfaceLanguage" | "team" | "owner" + | "restrictionScheduleId" + | "useBookerTimezone" > & { subsetOfUsers: BookerEventUser[]; showInstantEventConnectNowModal: boolean; + enablePerHostLocations?: boolean; } & { profile: BookerEventProfile }; -export type ValidationErrors = { key: FieldPath; error: ErrorOption }[]; - export type EventPrice = { currency: string; price: number; displayAlternateSymbol?: boolean }; export enum EventDetailBlocks { diff --git a/packages/features/eventtypes/lib/getPublicEvent.ts b/packages/features/eventtypes/lib/getPublicEvent.ts index 4f36bc6ebe..2d106e599a 100644 --- a/packages/features/eventtypes/lib/getPublicEvent.ts +++ b/packages/features/eventtypes/lib/getPublicEvent.ts @@ -172,6 +172,8 @@ export const getPublicEventSelect = (fetchAllUsers: boolean) => { hidden: true, assignAllTeamMembers: true, rescheduleWithSameRoundRobinHost: true, + restrictionScheduleId: true, + useBookerTimezone: true, parent: { select: { team: { @@ -341,6 +343,8 @@ export const getPublicEvent = async ( return { ...defaultEvent, bookingFields: getBookingFieldsWithSystemFields({ ...defaultEvent, disableBookingTitle }), + restrictionScheduleId: null, + useBookerTimezone: false, // Clears meta data since we don't want to send this in the public api. subsetOfUsers: users.map((user) => ({ ...user, @@ -592,6 +596,8 @@ export const getPublicEvent = async ( disableRescheduling: event.disableRescheduling, allowReschedulingCancelledBookings: event.allowReschedulingCancelledBookings, interfaceLanguage: event.interfaceLanguage, + restrictionScheduleId: event.restrictionScheduleId, + useBookerTimezone: event.useBookerTimezone, }; }; diff --git a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx index 7fc1bf16d3..4ff56558a8 100644 --- a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx +++ b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx @@ -7,9 +7,10 @@ import { useBookerStoreContext, useInitializeBookerStoreContext, } from "@calcom/features/bookings/Booker/BookerStoreProvider"; -import { useBookerLayout } from "@calcom/features/bookings/Booker/hooks/useBookerLayout"; -import { useBookingForm } from "@calcom/features/bookings/Booker/hooks/useBookingForm"; -import { useLocalSet } from "@calcom/features/bookings/Booker/hooks/useLocalSet"; +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 { useStableTimezone } from "@calcom/features/bookings/Booker/hooks/useStableTimezone"; import { useInitializeBookerStore } from "@calcom/features/bookings/Booker/store"; import { useTimePreferences } from "@calcom/features/bookings/lib"; import type { ConnectedDestinationCalendars } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars"; @@ -243,7 +244,13 @@ const BookerPlatformWrapperComponent = ( return restFormValues; }, [restFormValues]); - const { timezone } = useTimePreferences(); + const { timezone: rawTimezone } = useTimePreferences(); + const timezone = useStableTimezone( + rawTimezone, + event?.data?.restrictionScheduleId != null + ? { id: event.data.restrictionScheduleId, useBookerTimezone: event.data.useBookerTimezone } + : undefined + ); const [calculatedStartTime, calculatedEndTime] = useTimesForSchedule({ month, @@ -315,7 +322,7 @@ const BookerPlatformWrapperComponent = ( } }, [schedule.data, schedule.isPending, schedule.error, onTimeslotsLoaded]); - const bookerForm = useBookingForm({ + const bookerForm= useBookingForm({ event: event?.data, sessionEmail: session?.data?.email && clientId diff --git a/packages/platform/atoms/calendar-view/EventTypeCalendarViewComponent.tsx b/packages/platform/atoms/calendar-view/EventTypeCalendarViewComponent.tsx index b0b93ff1fd..258d99ce95 100644 --- a/packages/platform/atoms/calendar-view/EventTypeCalendarViewComponent.tsx +++ b/packages/platform/atoms/calendar-view/EventTypeCalendarViewComponent.tsx @@ -9,6 +9,7 @@ import { Header } from "@calcom/features/bookings/components/Header"; import { BookerSection } from "@calcom/features/bookings/components/Section"; import { useAvailableTimeSlots } from "@calcom/features/bookings/Booker/hooks/useAvailableTimeSlots"; import { useBookerLayout } from "@calcom/features/bookings/Booker/hooks/useBookerLayout"; +import { useStableTimezone } from "@calcom/features/bookings/Booker/hooks/useStableTimezone"; import { useTimePreferences } from "@calcom/features/bookings/lib"; import { LargeCalendar } from "./components/LargeCalendar"; import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents"; @@ -68,7 +69,13 @@ export const EventTypeCalendarViewComponent = ( bookerLayout, }); - const { timezone } = useTimePreferences(); + const { timezone: rawTimezone } = useTimePreferences(); + const timezone = useStableTimezone( + rawTimezone, + event?.data?.restrictionScheduleId != null + ? { id: event.data.restrictionScheduleId, useBookerTimezone: event.data.useBookerTimezone } + : undefined + ); const isDynamic = useMemo(() => { return getUsernameList(username ?? "").length > 1; }, [username]); @@ -112,7 +119,7 @@ export const EventTypeCalendarViewComponent = ( const selectedEventDuration = useBookerStoreContext((state) => state.selectedDuration); const eventDuration = selectedEventDuration || event?.data?.length || 30; - const availableTimeSlots = useAvailableTimeSlots({ schedule: schedule.data, eventDuration }); + const availableTimeSlots= useAvailableTimeSlots({ schedule: schedule.data, eventDuration }); return ( diff --git a/packages/trpc/server/routers/publicViewer/event.handler.ts b/packages/trpc/server/routers/publicViewer/event.handler.ts index 20d7944429..33c300e390 100644 --- a/packages/trpc/server/routers/publicViewer/event.handler.ts +++ b/packages/trpc/server/routers/publicViewer/event.handler.ts @@ -1,3 +1,4 @@ +import type { PublicEventType } from "@calcom/features/eventtypes/lib/getPublicEvent"; import { EventRepository } from "@calcom/features/eventtypes/repositories/EventRepository"; import type { TEventInputSchema } from "./event.schema"; @@ -7,7 +8,7 @@ interface EventHandlerOptions { userId?: number; } -export const eventHandler = async ({ input, userId }: EventHandlerOptions) => { +export const eventHandler = async ({ input, userId }: EventHandlerOptions): Promise => { return await EventRepository.getPublicEvent(input, userId); };