From 52d5261b5105bba36c089b673967bfeec57c8e30 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Mon, 15 Dec 2025 17:04:00 +0100 Subject: [PATCH] feat(booking): implement cross-page/week navigation in booking details sheet with view persistence (#25545) * display UTM parameters * persist view (list | calendar) in localStorage * move to next page on clicking "next" of last item in the page improve navigation ^ Conflicts: ^ apps/web/modules/bookings/components/BookingCalendarContainer.tsx * fix navigation on calendar view * Delete apps/web/modules/bookings/NAVIGATION_IMPLEMENTATION.md * avoid page size being 0 * check feature flag on user level * use map to improve useBookingListData * address feedback * feat(bookings): add smart navigation for calendar view - Add sort option to tRPC bookings.get schema and handler - Create NAVIGATION_PROBE_WINDOW_MONTHS constant (3 months) - Create useNearestFutureBooking hook to find nearest future booking - Create useNearestPastBooking hook to find nearest past booking - Update useCalendarNavigationCapabilities to use probe results - Update BookingCalendarContainer to wire up probe hooks This enables the booking details sheet to: - Disable next/prev buttons when no bookings exist in that direction - Jump directly to the week containing the nearest booking Co-Authored-By: eunjae@cal.com * fix type error * feat: add booking selection state when using slideover (#25637) * implement booking selection when using slideover * remove pixel shift * fix * auto scroll to selected event on calendar * make DateValues header sticky when scrolling --------- Co-authored-by: Eunjae Lee * prefetch previous / next weeks on calendar view * clean up classes * handle "fetched & but no data" situation more correctly in useCalendarAutoSelector --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> --- .../(main-nav)/bookings/[status]/page.tsx | 6 +- .../components/booking/BookingListItem.tsx | 32 ++- .../components/BookingCalendarContainer.tsx | 52 +++-- .../components/BookingCalendarView.tsx | 2 + .../components/BookingDetailsSheet.tsx | 83 +++++--- .../components/BookingListContainer.tsx | 52 ++++- .../bookings/components/ViewToggleButton.tsx | 18 +- .../bookings/hooks/useBookingListData.ts | 20 +- .../modules/bookings/hooks/useBookingsView.ts | 104 ++++++++++ .../bookings/hooks/useCalendarAutoSelector.ts | 84 ++++++++ .../useCalendarNavigationCapabilities.ts | 126 ++++++++++++ .../bookings/hooks/useListAutoSelector.ts | 37 ++++ .../hooks/useListNavigationCapabilities.ts | 75 +++++++ .../bookings/hooks/useNearestFutureBooking.ts | 54 ++++++ .../bookings/hooks/useNearestPastBooking.ts | 55 ++++++ apps/web/modules/bookings/lib/constants.ts | 6 + apps/web/modules/bookings/lib/viewParser.ts | 11 -- .../store/bookingDetailsSheetStore.tsx | 183 ++++++++++++++---- .../modules/bookings/views/bookings-view.tsx | 13 +- .../services/BookingDetailsService.ts | 1 + .../weeklyview/components/Calendar.tsx | 16 +- .../weeklyview/components/event/Event.tsx | 4 +- .../weeklyview/components/event/EventList.tsx | 35 +++- .../calendars/weeklyview/state/store.ts | 1 + .../calendars/weeklyview/types/state.ts | 4 + .../components/DataTableWrapper.tsx | 36 ++++ packages/features/data-table/lib/parsers.ts | 12 +- packages/prisma/selects/booking.ts | 9 + .../routers/viewer/bookings/get.handler.ts | 1 + .../routers/viewer/bookings/get.schema.ts | 6 + 30 files changed, 997 insertions(+), 141 deletions(-) create mode 100644 apps/web/modules/bookings/hooks/useBookingsView.ts create mode 100644 apps/web/modules/bookings/hooks/useCalendarAutoSelector.ts create mode 100644 apps/web/modules/bookings/hooks/useCalendarNavigationCapabilities.ts create mode 100644 apps/web/modules/bookings/hooks/useListAutoSelector.ts create mode 100644 apps/web/modules/bookings/hooks/useListNavigationCapabilities.ts create mode 100644 apps/web/modules/bookings/hooks/useNearestFutureBooking.ts create mode 100644 apps/web/modules/bookings/hooks/useNearestPastBooking.ts create mode 100644 apps/web/modules/bookings/lib/constants.ts delete mode 100644 apps/web/modules/bookings/lib/viewParser.ts diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx index b00c69ce9a..fe7fedbf51 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx @@ -53,11 +53,9 @@ const Page = async ({ params }: PageProps) => { canReadOthersBookings = teamIdsWithPermission.length > 0; } - const userProfile = session?.user?.profile; - const orgId = userProfile?.organizationId ?? session?.user.org?.id; const featuresRepository = new FeaturesRepository(prisma); - const bookingsV3Enabled = orgId - ? await featuresRepository.checkIfTeamHasFeature(orgId, "bookings-v3") + const bookingsV3Enabled = session?.user?.id + ? await featuresRepository.checkIfUserHasFeature(session.user.id, "bookings-v3") : false; return ( diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 394f9d96b9..9303e862f2 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData"; @@ -41,6 +41,7 @@ import { Tooltip } from "@calcom/ui/components/tooltip"; import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap"; import { buildBookingLink } from "../../modules/bookings/lib/buildBookingLink"; +import { useBookingDetailsSheetStore } from "../../modules/bookings/store/bookingDetailsSheetStore"; import type { BookingAttendee } from "../../modules/bookings/types"; import { AcceptBookingButton } from "./AcceptBookingButton"; import { RejectBookingButton } from "./RejectBookingButton"; @@ -136,6 +137,7 @@ const ConditionalLink = ({ function BookingListItem(booking: BookingItemProps) { const parsedBooking = buildParsedBooking(booking); + const itemRef = useRef(null); const { userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser; const { onClick } = booking; @@ -144,6 +146,21 @@ function BookingListItem(booking: BookingItemProps) { i18n: { language }, } = useLocale(); + // Get selected booking UID from store + // The provider should always be available when BookingListItem is rendered (bookingsV3Enabled is true) + const selectedBookingUid = useBookingDetailsSheetStore((state) => state.selectedBookingUid); + const isSelected = !!selectedBookingUid && selectedBookingUid === booking.uid; + + // Scroll into view when this booking becomes selected + useEffect(() => { + if (isSelected && itemRef.current) { + itemRef.current.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + } + }, [isSelected]); + const attendeeList = booking.attendees.map((attendee) => ({ ...attendee, noShow: attendee.noShow || false, @@ -265,10 +282,17 @@ function BookingListItem(booking: BookingItemProps) { return (
+ data-booking-uid={booking.uid} + className={classNames( + "group relative w-full transition-all duration-100 ease-out", + "hover:bg-cal-muted", + isSelected && + "bg-cal-muted before:bg-brand-default rounded-r-md before:absolute before:left-0 before:top-0 before:h-full before:w-1" + )}>
@@ -381,7 +405,7 @@ function BookingListItem(booking: BookingItemProps) {
{title} @@ -395,7 +419,7 @@ function BookingListItem(booking: BookingItemProps) {
{booking.description && (
"{booking.description}"
diff --git a/apps/web/modules/bookings/components/BookingCalendarContainer.tsx b/apps/web/modules/bookings/components/BookingCalendarContainer.tsx index 546627d64a..37a0c273f5 100644 --- a/apps/web/modules/bookings/components/BookingCalendarContainer.tsx +++ b/apps/web/modules/bookings/components/BookingCalendarContainer.tsx @@ -16,6 +16,8 @@ import { Icon } from "@calcom/ui/components/icon"; import { useBookingCalendarData } from "~/bookings/hooks/useBookingCalendarData"; import { useBookingFilters } from "~/bookings/hooks/useBookingFilters"; import { useCalendarAllowedFilters } from "~/bookings/hooks/useCalendarAllowedFilters"; +import { useCalendarAutoSelector } from "~/bookings/hooks/useCalendarAutoSelector"; +import { useCalendarNavigationCapabilities } from "~/bookings/hooks/useCalendarNavigationCapabilities"; import { useCurrentWeekStart } from "~/bookings/hooks/useCurrentWeekStart"; import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues"; @@ -36,6 +38,7 @@ interface BookingCalendarContainerProps { permissions: { canReadOthersBookings: boolean; }; + bookingsV3Enabled: boolean; } interface BookingCalendarInnerProps extends BookingCalendarContainerProps { @@ -48,20 +51,39 @@ interface BookingCalendarInnerProps extends BookingCalendarContainerProps { isPending: boolean; hasError: boolean; errorMessage?: string; + hasNextPage: boolean; + isFetched: boolean; + isFetchingNextPage: boolean; } function BookingCalendarInner({ status, permissions, + bookingsV3Enabled, data, allowedFilterIds, hasError, errorMessage, + hasNextPage, + isFetched, + isFetchingNextPage, }: BookingCalendarInnerProps) { const { t } = useLocale(); const user = useMeQuery().data; const { currentWeekStart, setCurrentWeekStart, userWeekStart } = useCurrentWeekStart(); + const rowData = useBookingCalendarData({ data, status }); + + // Extract bookings from table data + const bookings = useMemo(() => { + return rowData + .filter((row): row is Extract => row.type === "data") + .map((row) => row.booking); + }, [rowData]); + + // Handle auto-selection for calendar view + useCalendarAutoSelector(bookings, hasNextPage, isFetched, isFetchingNextPage); + const goToPreviousWeek = () => { setCurrentWeekStart(currentWeekStart.subtract(1, "week")); }; @@ -86,8 +108,6 @@ function BookingCalendarInner({ const getFacetedUniqueValues = useFacetedUniqueValues(); - const rowData = useBookingCalendarData({ data, status }); - const table = useReactTable({ data: rowData, columns, @@ -99,13 +119,6 @@ function BookingCalendarInner({ getFacetedUniqueValues, }); - // Extract bookings from table data - const bookings = useMemo(() => { - return rowData - .filter((row): row is Extract => row.type === "data") - .map((row) => row.booking); - }, [rowData]); - return ( <>
@@ -132,7 +145,7 @@ function BookingCalendarInner({ - +
{hasError && ErrorView ? ( @@ -158,7 +171,7 @@ function BookingCalendarInner({ export function BookingCalendarContainer(props: BookingCalendarContainerProps) { const { canReadOthersBookings } = props.permissions; const { userIds } = useBookingFilters(); - const { currentWeekStart } = useCurrentWeekStart(); + const { currentWeekStart, setCurrentWeekStart, userWeekStart } = useCurrentWeekStart(); const allowedFilterIds = useCalendarAllowedFilters({ canReadOthersBookings, @@ -182,8 +195,9 @@ export function BookingCalendarContainer(props: BookingCalendarContainerProps) { } ); + const { isFetched, hasNextPage, isFetchingNextPage, fetchNextPage } = query; + // Automatically fetch all pages until no more data - const { hasNextPage, isFetchingNextPage, fetchNextPage } = query; useEffect(() => { if (hasNextPage && !isFetchingNextPage) { fetchNextPage(); @@ -208,8 +222,17 @@ export function BookingCalendarContainer(props: BookingCalendarContainerProps) { const bookings = useMemo(() => data?.bookings ?? [], [data?.bookings]); + // Create navigation capabilities for calendar view + // This hook handles probe queries and prefetching internally + const capabilities = useCalendarNavigationCapabilities({ + currentWeekStart, + setCurrentWeekStart, + userWeekStart, + filters: { statuses: STATUSES, userIds }, + }); + return ( - + ); diff --git a/apps/web/modules/bookings/components/BookingCalendarView.tsx b/apps/web/modules/bookings/components/BookingCalendarView.tsx index 7fd599b7a0..0ed86bb0fa 100644 --- a/apps/web/modules/bookings/components/BookingCalendarView.tsx +++ b/apps/web/modules/bookings/components/BookingCalendarView.tsx @@ -24,6 +24,7 @@ export function BookingCalendarView({ onWeekStartChange, }: BookingCalendarViewProps) { const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid); + const selectedBookingUid = useBookingDetailsSheetStore((state) => state.selectedBookingUid); const { timezone } = useTimePreferences(); const { resolvedTheme, forcedTheme } = useGetTheme(); const { bannersHeight } = useBanners(); @@ -91,6 +92,7 @@ export function BookingCalendarView({ showBackgroundPattern={false} showBorder={false} borderColor="subtle" + selectedBookingUid={selectedBookingUid} onEventClick={(event) => { const bookingUid = event.options?.bookingUid; if (bookingUid) { diff --git a/apps/web/modules/bookings/components/BookingDetailsSheet.tsx b/apps/web/modules/bookings/components/BookingDetailsSheet.tsx index 0ec6afb171..8dd1a554f6 100644 --- a/apps/web/modules/bookings/components/BookingDetailsSheet.tsx +++ b/apps/web/modules/bookings/components/BookingDetailsSheet.tsx @@ -42,10 +42,7 @@ import { BookingActionsDropdown } from "../../../components/booking/actions/Book import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider"; import type { BookingListingStatus } from "../../../components/booking/types"; import { usePaymentStatus } from "../hooks/usePaymentStatus"; -import { - useBookingDetailsSheetStore, - useBookingDetailsSheetStoreApi, -} from "../store/bookingDetailsSheetStore"; +import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore"; import type { BookingOutput } from "../types"; import { JoinMeetingButton } from "./JoinMeetingButton"; @@ -103,35 +100,38 @@ function BookingDetailsSheetInner({ const { data: bookingDetails } = trpc.viewer.bookings.getBookingDetails.useQuery( { uid: booking.uid }, { - enabled: Boolean(booking.rescheduled || booking.fromReschedule), // Keep data fresh but don't refetch too aggressively staleTime: 5 * 60 * 1000, // 5 minutes } ); - // Get navigation state directly from the store - const hasNext = useBookingDetailsSheetStore((state) => state.hasNext()); - const hasPrevious = useBookingDetailsSheetStore((state) => state.hasPrevious()); - const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid); + // Get navigation state from the store in a single selector + const navigation = useBookingDetailsSheetStore((state) => { + const hasNextInArray = state.hasNextInArray(); + const hasPreviousInArray = state.hasPreviousInArray(); + const isLastInArray = state.isLastInArray(); + const isFirstInArray = state.isFirstInArray(); + + return { + navigateNext: state.navigateNext, + navigatePrevious: state.navigatePrevious, + isTransitioning: state.isTransitioning, + setSelectedBookingUid: state.setSelectedBookingUid, + canGoNext: hasNextInArray || (isLastInArray && state.capabilities?.canNavigateToNextPeriod()), + canGoPrev: hasPreviousInArray || (isFirstInArray && state.capabilities?.canNavigateToPreviousPeriod()), + }; + }); const handleClose = () => { - setSelectedBookingUid(null); + navigation.setSelectedBookingUid(null); }; - const storeApi = useBookingDetailsSheetStoreApi(); - const handleNext = () => { - const nextUid = storeApi.getState().getNextBookingUid(); - if (nextUid !== null) { - setSelectedBookingUid(nextUid); - } + navigation.navigateNext(); }; const handlePrevious = () => { - const prevUid = storeApi.getState().getPreviousBookingUid(); - if (prevUid !== null) { - setSelectedBookingUid(prevUid); - } + navigation.navigatePrevious(); }; const startTime = dayjs(booking.startTime).tz(userTimeZone); @@ -214,7 +214,7 @@ function BookingDetailsSheetInner({ size="sm" color="secondary" StartIcon="chevron-up" - disabled={!hasPrevious} + disabled={!navigation.canGoPrev || navigation.isTransitioning} onClick={(e) => { e.preventDefault(); handlePrevious(); @@ -225,7 +225,7 @@ function BookingDetailsSheetInner({ size="sm" color="secondary" StartIcon="chevron-down" - disabled={!hasNext} + disabled={!navigation.canGoNext || navigation.isTransitioning} onClick={(e) => { e.preventDefault(); handleNext(); @@ -289,6 +289,8 @@ function BookingDetailsSheetInner({ customResponses={customResponses} bookingFields={booking.eventType?.bookingFields} /> + +
@@ -841,6 +843,43 @@ function BookingHeaderBadges({ ); } +function TrackingSection({ + tracking, +}: { + tracking?: { + utm_source: string | null; + utm_medium: string | null; + utm_campaign: string | null; + utm_term: string | null; + utm_content: string | null; + } | null; +}) { + const { t } = useLocale(); + + if (!tracking) { + return null; + } + + const utmEntries = Object.entries(tracking).filter(([_, value]) => Boolean(value)); + + if (utmEntries.length === 0) { + return null; + } + + return ( +
+
+ {utmEntries.map(([key, value]) => ( +
+ {key}:{" "} + {value} +
+ ))} +
+
+ ); +} + function Section({ title, className, diff --git a/apps/web/modules/bookings/components/BookingListContainer.tsx b/apps/web/modules/bookings/components/BookingListContainer.tsx index 33e5835203..6a01a5878c 100644 --- a/apps/web/modules/bookings/components/BookingListContainer.tsx +++ b/apps/web/modules/bookings/components/BookingListContainer.tsx @@ -25,6 +25,8 @@ import { useBookingListColumns } from "~/bookings/hooks/useBookingListColumns"; import { useBookingListData } from "~/bookings/hooks/useBookingListData"; import { useBookingStatusTab } from "~/bookings/hooks/useBookingStatusTab"; import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues"; +import { useListAutoSelector } from "~/bookings/hooks/useListAutoSelector"; +import { useListNavigationCapabilities } from "~/bookings/hooks/useListNavigationCapabilities"; import { BookingDetailsSheetStoreProvider, @@ -77,11 +79,13 @@ interface BookingListInnerProps extends BookingListContainerProps { hasError: boolean; errorMessage?: string; totalRowCount?: number; + bookings: BookingsGetOutput["bookings"]; } function BookingListInner({ status, permissions, + bookings, bookingsV3Enabled, data, isPending, @@ -95,6 +99,9 @@ function BookingListInner({ const router = useRouter(); const [showFilters, setShowFilters] = useState(true); + // Handle auto-selection for list view + useListAutoSelector(bookings); + const ErrorView = errorMessage ? ( ) : undefined; @@ -180,7 +187,7 @@ function BookingListInner({
- {bookingsV3Enabled && } + {bookingsV3Enabled && }
{displayedFilterCount > 0 && showFilters && (
@@ -221,12 +228,13 @@ function BookingListInner({ } export function BookingListContainer(props: BookingListContainerProps) { - const { limit, offset } = useDataTable(); + const { limit, offset, setPageIndex } = useDataTable(); const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } = useBookingFilters(); - const query = trpc.viewer.bookings.get.useQuery( - { + // Build query input once - shared between query and prefetching + const queryInput = useMemo( + () => ({ limit, offset, filters: { @@ -242,17 +250,40 @@ export function BookingListContainer(props: BookingListContainerProps) { : undefined, beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined, }, - }, - { - staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh - gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time - } + }), + [ + limit, + offset, + props.status, + eventTypeIds, + teamIds, + userIds, + attendeeName, + attendeeEmail, + bookingUid, + dateRange, + ] ); + const query = trpc.viewer.bookings.get.useQuery(queryInput, { + staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh + gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time + }); + const bookings = useMemo(() => query.data?.bookings ?? [], [query.data?.bookings]); + // Always call the hook and provide navigation capabilities + // The BookingDetailsSheet is only rendered when bookingsV3Enabled is true (see line 212) + const capabilities = useListNavigationCapabilities({ + limit, + offset, + totalCount: query.data?.totalCount, + setPageIndex, + queryInput, + }); + return ( - + ); diff --git a/apps/web/modules/bookings/components/ViewToggleButton.tsx b/apps/web/modules/bookings/components/ViewToggleButton.tsx index 111e7caf57..baf80abb28 100644 --- a/apps/web/modules/bookings/components/ViewToggleButton.tsx +++ b/apps/web/modules/bookings/components/ViewToggleButton.tsx @@ -1,6 +1,5 @@ "use client"; -import { useQueryState } from "nuqs"; import { useEffect } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -8,14 +7,15 @@ import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; import { ToggleGroup } from "@calcom/ui/components/form"; import { Icon } from "@calcom/ui/components/icon"; -import { viewParser, type BookingView } from "../lib/viewParser"; +import { useBookingsView } from "../hooks/useBookingsView"; -export function ViewToggleButton() { +type ViewToggleButtonProps = { + bookingsV3Enabled: boolean; +}; + +export function ViewToggleButton({ bookingsV3Enabled }: ViewToggleButtonProps) { const { t } = useLocale(); - const [view, setView] = useQueryState( - "view", - viewParser.withDefault("list").withOptions({ clearOnDefault: true }) - ); + const [view, setView] = useBookingsView({ bookingsV3Enabled }); const isMobile = useMediaQuery("(max-width: 768px)"); useEffect(() => { @@ -33,9 +33,9 @@ export function ViewToggleButton() {
{ + onValueChange={(value: "list" | "calendar") => { if (!value) return; - setView(value as BookingView); + setView(value); }} options={[ { diff --git a/apps/web/modules/bookings/hooks/useBookingListData.ts b/apps/web/modules/bookings/hooks/useBookingListData.ts index d1e4cffa94..9fa60f9b0b 100644 --- a/apps/web/modules/bookings/hooks/useBookingListData.ts +++ b/apps/web/modules/bookings/hooks/useBookingListData.ts @@ -26,6 +26,18 @@ export function useBookingListData({ userTimeZone?: string; }) { const { t } = useLocale(); + + // Build a Map for recurringInfo lookups + const recurringInfoMap = useMemo(() => { + const map = new Map["recurringInfo"][number]>(); + for (const info of data?.recurringInfo ?? []) { + if (info.recurringEventId) { + map.set(info.recurringEventId, info); + } + } + return map; + }, [data?.recurringInfo]); + /** * Transform raw bookings into flat list (excluding today's bookings for "upcoming" status) * - Deduplicates recurring bookings for recurring/unconfirmed/cancelled tabs @@ -70,11 +82,11 @@ export function useBookingListData({ data?.bookings.filter(filterBookings).map((booking) => ({ type: "data" as const, booking, - recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId), + recurringInfo: booking.recurringEventId ? recurringInfoMap.get(booking.recurringEventId) : undefined, isToday: false, })) || [] ); - }, [data, status, userTimeZone]); + }, [data?.bookings, recurringInfoMap, status, userTimeZone]); // Extract today's bookings for the "Today" section (only used in "upcoming" status) const bookingsToday = useMemo(() => { @@ -88,10 +100,10 @@ export function useBookingListData({ .map((booking) => ({ type: "data" as const, booking, - recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId), + recurringInfo: booking.recurringEventId ? recurringInfoMap.get(booking.recurringEventId) : undefined, isToday: true, })); - }, [data, userTimeZone]); + }, [data?.bookings, recurringInfoMap, userTimeZone]); // Combine data with section separators for "upcoming" tab const finalData = useMemo(() => { diff --git a/apps/web/modules/bookings/hooks/useBookingsView.ts b/apps/web/modules/bookings/hooks/useBookingsView.ts new file mode 100644 index 0000000000..864575653d --- /dev/null +++ b/apps/web/modules/bookings/hooks/useBookingsView.ts @@ -0,0 +1,104 @@ +import { createParser, useQueryState } from "nuqs"; +import { useEffect, useRef, useSyncExternalStore } from "react"; + +import { localStorage } from "@calcom/lib/webstorage"; + +const STORAGE_KEY = "bookings-preferred-view"; + +type BookingView = "list" | "calendar"; + +const viewParser = createParser({ + parse: (value: string) => { + if (value === "calendar") return "calendar"; + return "list"; + }, + serialize: (value: BookingView) => value, +}); + +// Create a store for localStorage value +const createLocalStorageStore = () => { + let listeners: Array<() => void> = []; + + const subscribe = (listener: () => void) => { + listeners.push(listener); + return () => { + listeners = listeners.filter((l) => l !== listener); + }; + }; + + const getSnapshot = (): BookingView => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === "list" || stored === "calendar") { + return stored; + } + return "list"; + }; + + const getServerSnapshot = (): BookingView => { + return "list"; + }; + + const notify = () => { + listeners.forEach((listener) => listener()); + }; + + return { subscribe, getSnapshot, getServerSnapshot, notify }; +}; + +const localStorageStore = createLocalStorageStore(); + +type UseBookingsViewOptions = { + bookingsV3Enabled: boolean; +}; + +export function useBookingsView({ bookingsV3Enabled }: UseBookingsViewOptions) { + // Always use "list" as the default for useQueryState to keep instances in sync + const [_view, setView] = useQueryState("view", viewParser.withDefault("list")); + + // Track if we've completed the initial sync to prevent race conditions + const isInitializedRef = useRef(false); + + // Read from localStorage using useSyncExternalStore + const storedView = useSyncExternalStore( + localStorageStore.subscribe, + localStorageStore.getSnapshot, + localStorageStore.getServerSnapshot + ); + + // Force view to be "list" if calendar view is disabled + const view = bookingsV3Enabled ? _view : "list"; + + // Sync localStorage value to URL on initial mount + useEffect(() => { + // Only sync if there's no URL parameter AND localStorage has a non-default value + const urlHasViewParam = + typeof window !== "undefined" && new URLSearchParams(window.location.search).has("view"); + + if (!urlHasViewParam && storedView !== "list" && _view !== storedView) { + setView(storedView); + } else { + // No sync needed, mark as initialized + isInitializedRef.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Mark as initialized when _view matches storedView after initial sync + useEffect(() => { + if (!isInitializedRef.current && _view === storedView) { + isInitializedRef.current = true; + } + }, [_view, storedView]); + + // Sync to localStorage when view changes (only if initialized) + useEffect(() => { + if (!isInitializedRef.current) return; + + if (bookingsV3Enabled && view && view !== storedView) { + localStorage.setItem(STORAGE_KEY, view); + localStorageStore.notify(); // Notify all subscribers + } + }, [view, storedView, bookingsV3Enabled]); + + return [view, setView] as const; +} diff --git a/apps/web/modules/bookings/hooks/useCalendarAutoSelector.ts b/apps/web/modules/bookings/hooks/useCalendarAutoSelector.ts new file mode 100644 index 0000000000..40a799cb91 --- /dev/null +++ b/apps/web/modules/bookings/hooks/useCalendarAutoSelector.ts @@ -0,0 +1,84 @@ +import { useEffect, useRef } from "react"; + +import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore"; +import type { BookingsGetOutput } from "../types"; + +/** + * Calendar-specific auto-selection logic hook. + * Handles auto-selecting bookings when navigating across weeks in calendar view. + * + * - For "first": Selects as soon as the first page loads (immediate) + * - For "last": Waits for all pages to load to ensure we get the actual last booking + */ +export function useCalendarAutoSelector( + bookings: BookingsGetOutput["bookings"], + hasNextPage: boolean, + isFetched: boolean, + isFetchingNextPage: boolean +) { + const pendingSelection = useBookingDetailsSheetStore((state) => state.pendingSelection); + const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid); + const clearPendingSelection = useBookingDetailsSheetStore((state) => state.clearPendingSelection); + const setIsTransitioning = useBookingDetailsSheetStore((state) => state.setIsTransitioning); + const bookingsRef = useRef(bookings); + + useEffect(() => { + // Early return if no pending selection + if (!pendingSelection) { + return; + } + + const hasBookingsChanged = bookings !== bookingsRef.current; + bookingsRef.current = bookings; + + if (!hasBookingsChanged) return; + + if (pendingSelection === "first" && isFetched && bookings.length === 0) { + // data fetching is finished but there is no booking to select + setIsTransitioning(false); + clearPendingSelection(); + return; + } + + // For "first", we can select immediately when the first page arrives + if (pendingSelection === "first" && bookings.length > 0) { + setSelectedBookingUid(bookings[0].uid); + setIsTransitioning(false); + clearPendingSelection(); + return; + } + + // For "last", wait until all pages are loaded to ensure we get the actual last booking + if (pendingSelection === "last") { + const isAllDataLoaded = !hasNextPage && !isFetchingNextPage; + + if (isAllDataLoaded && bookings.length === 0) { + // data fetching is finished but there is no booking to select + setIsTransitioning(false); + clearPendingSelection(); + return; + } + + // Wait for all data to load AND for bookings to actually arrive + if (!isAllDataLoaded || bookings.length === 0) { + return; + } + + const lastBooking = bookings[bookings.length - 1]; + setSelectedBookingUid(lastBooking.uid); + + // Clear transition state and pending selection after handling + setIsTransitioning(false); + clearPendingSelection(); + } + }, [ + bookings, + hasNextPage, + isFetched, + isFetchingNextPage, + pendingSelection, + setSelectedBookingUid, + clearPendingSelection, + setIsTransitioning, + ]); +} diff --git a/apps/web/modules/bookings/hooks/useCalendarNavigationCapabilities.ts b/apps/web/modules/bookings/hooks/useCalendarNavigationCapabilities.ts new file mode 100644 index 0000000000..6627f32ee7 --- /dev/null +++ b/apps/web/modules/bookings/hooks/useCalendarNavigationCapabilities.ts @@ -0,0 +1,126 @@ +import { useCallback, useMemo, useEffect } from "react"; + +import type { Dayjs } from "@calcom/dayjs"; +import dayjs from "@calcom/dayjs"; +import { trpc } from "@calcom/trpc/react"; + +import { getWeekStart } from "../lib/weekUtils"; +import type { NavigationCapabilities } from "../store/bookingDetailsSheetStore"; +import type { BookingListingStatus } from "../types"; +import { useNearestFutureBooking } from "./useNearestFutureBooking"; +import { useNearestPastBooking } from "./useNearestPastBooking"; + +interface UseCalendarNavigationCapabilitiesProps { + currentWeekStart: Dayjs; + setCurrentWeekStart: (date: Dayjs) => void; + userWeekStart: number; + /** Filters to use for probe queries and prefetching */ + filters: { + statuses: BookingListingStatus[]; + userIds?: number[]; + }; +} + +/** + * Calendar view navigation capabilities adapter. + * Provides week-based navigation logic for the booking details sheet. + * + * This hook: + * - Uses probe queries to find nearest bookings in each direction + * - Jumps directly to the week containing the nearest booking (not just adjacent week) + * - Disables navigation buttons when no bookings exist in that direction + * - Prefetches booking data for the target weeks to improve navigation performance + */ +export function useCalendarNavigationCapabilities({ + currentWeekStart, + setCurrentWeekStart, + userWeekStart, + filters, +}: UseCalendarNavigationCapabilitiesProps): NavigationCapabilities { + const trpcUtils = trpc.useUtils(); + + // Probe queries for navigation - find nearest bookings in each direction + const { nearestBooking: nearestFutureBooking } = useNearestFutureBooking({ + currentWeekStart, + filters, + }); + + const { nearestBooking: nearestPastBooking } = useNearestPastBooking({ + currentWeekStart, + filters, + }); + + const hasFutureBooking = !!nearestFutureBooking; + const hasPastBooking = !!nearestPastBooking; + const nextBookingDate = nearestFutureBooking?.startTime?.toString() ?? null; + const prevBookingDate = nearestPastBooking?.startTime?.toString() ?? null; + + // Calculate target week starts for prefetching + const nextWeekStart = useMemo(() => { + if (!nextBookingDate) return null; + return getWeekStart(dayjs(nextBookingDate), userWeekStart); + }, [nextBookingDate, userWeekStart]); + + const prevWeekStart = useMemo(() => { + if (!prevBookingDate) return null; + return getWeekStart(dayjs(prevBookingDate), userWeekStart); + }, [prevBookingDate, userWeekStart]); + + // Prefetch bookings for the next week with bookings + // Uses prefetchInfinite to match the useInfiniteQuery used in BookingCalendarContainer + useEffect(() => { + if (!nextWeekStart) return; + + trpcUtils.viewer.bookings.get.prefetchInfinite({ + limit: 100, + filters: { + statuses: filters.statuses, + userIds: filters.userIds, + afterStartDate: nextWeekStart.startOf("day").toISOString(), + beforeEndDate: nextWeekStart.add(6, "day").endOf("day").toISOString(), + }, + }); + }, [nextWeekStart, filters.statuses, filters.userIds, trpcUtils]); + + // Prefetch bookings for the previous week with bookings + // Uses prefetchInfinite to match the useInfiniteQuery used in BookingCalendarContainer + useEffect(() => { + if (!prevWeekStart) return; + + trpcUtils.viewer.bookings.get.prefetchInfinite({ + limit: 100, + filters: { + statuses: filters.statuses, + userIds: filters.userIds, + afterStartDate: prevWeekStart.startOf("day").toISOString(), + beforeEndDate: prevWeekStart.add(6, "day").endOf("day").toISOString(), + }, + }); + }, [prevWeekStart, filters.statuses, filters.userIds, trpcUtils]); + + const canNavigateToNextPeriod = useCallback(() => hasFutureBooking, [hasFutureBooking]); + + const canNavigateToPreviousPeriod = useCallback(() => hasPastBooking, [hasPastBooking]); + + const requestNextPeriod = useCallback(() => { + if (!nextBookingDate) return; + const targetWeekStart = getWeekStart(dayjs(nextBookingDate), userWeekStart); + setCurrentWeekStart(targetWeekStart); + }, [nextBookingDate, setCurrentWeekStart, userWeekStart]); + + const requestPreviousPeriod = useCallback(() => { + if (!prevBookingDate) return; + const targetWeekStart = getWeekStart(dayjs(prevBookingDate), userWeekStart); + setCurrentWeekStart(targetWeekStart); + }, [prevBookingDate, setCurrentWeekStart, userWeekStart]); + + return useMemo( + () => ({ + canNavigateToNextPeriod, + canNavigateToPreviousPeriod, + requestNextPeriod, + requestPreviousPeriod, + }), + [canNavigateToNextPeriod, canNavigateToPreviousPeriod, requestNextPeriod, requestPreviousPeriod] + ); +} diff --git a/apps/web/modules/bookings/hooks/useListAutoSelector.ts b/apps/web/modules/bookings/hooks/useListAutoSelector.ts new file mode 100644 index 0000000000..7d558d2f3c --- /dev/null +++ b/apps/web/modules/bookings/hooks/useListAutoSelector.ts @@ -0,0 +1,37 @@ +import { useEffect, useRef } from "react"; + +import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore"; +import type { BookingsGetOutput } from "../types"; + +/** + * List-specific auto-selection logic hook. + * Handles auto-selecting bookings when navigating across pages in list view. + * For list view, data is loaded per page, so we can select immediately when bookings change. + */ +export function useListAutoSelector(bookings: BookingsGetOutput["bookings"]) { + const pendingSelection = useBookingDetailsSheetStore((state) => state.pendingSelection); + const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid); + const clearPendingSelection = useBookingDetailsSheetStore((state) => state.clearPendingSelection); + const setIsTransitioning = useBookingDetailsSheetStore((state) => state.setIsTransitioning); + const bookingsRef = useRef(bookings); + + useEffect(() => { + const hasBookingsChanged = bookings !== bookingsRef.current; + // Always track current bookings to avoid stale ref when pendingSelection is later set + bookingsRef.current = bookings; + + // Early return if no pending selection + if (!pendingSelection) return; + + if (!hasBookingsChanged) return; + + if (bookings.length > 0) { + const bookingToSelect = pendingSelection === "first" ? bookings[0] : bookings[bookings.length - 1]; + setSelectedBookingUid(bookingToSelect.uid); + } + + // Always clear transition state and pending selection after handling + setIsTransitioning(false); + clearPendingSelection(); + }, [bookings, pendingSelection, setSelectedBookingUid, clearPendingSelection, setIsTransitioning]); +} diff --git a/apps/web/modules/bookings/hooks/useListNavigationCapabilities.ts b/apps/web/modules/bookings/hooks/useListNavigationCapabilities.ts new file mode 100644 index 0000000000..b4f4887f21 --- /dev/null +++ b/apps/web/modules/bookings/hooks/useListNavigationCapabilities.ts @@ -0,0 +1,75 @@ +import { useMemo, useEffect } from "react"; + +import { trpc } from "@calcom/trpc/react"; + +import type { NavigationCapabilities } from "../store/bookingDetailsSheetStore"; + +interface UseListNavigationCapabilitiesProps { + limit: number; + offset: number; + totalCount: number | undefined; + setPageIndex: (index: number) => void; + queryInput: Parameters[0]; +} + +/** + * List view navigation capabilities adapter. + * Provides pagination-specific navigation logic for the booking details sheet. + * + * This hook: + * - Calculates page boundaries based on limit/offset + * - Prefetches the next page when available (using the same query params as parent) + * - Provides methods to navigate between pages + */ +export function useListNavigationCapabilities({ + limit, + offset, + totalCount, + setPageIndex, + queryInput, +}: UseListNavigationCapabilitiesProps): NavigationCapabilities { + const trpcUtils = trpc.useUtils(); + const currentPageIndex = limit > 0 ? offset / limit : 0; + + // Calculate if there are more pages + const hasNextPage = useMemo(() => { + if (!totalCount) return false; + return offset + limit < totalCount; + }, [offset, limit, totalCount]); + + const hasPreviousPage = offset > 0; + + // Build query params for next page by reusing parent's query input + const nextPageParams = useMemo( + () => ({ + ...queryInput, + offset: (currentPageIndex + 1) * limit, + }), + [queryInput, currentPageIndex, limit] + ); + + // Prefetch next page when it exists + useEffect(() => { + if (hasNextPage) { + trpcUtils.viewer.bookings.get.prefetch(nextPageParams); + } + }, [hasNextPage, nextPageParams, trpcUtils]); + + return useMemo( + () => ({ + canNavigateToNextPeriod: () => hasNextPage, + canNavigateToPreviousPeriod: () => hasPreviousPage, + + requestNextPeriod: () => { + if (!hasNextPage) return; + setPageIndex(currentPageIndex + 1); + }, + + requestPreviousPeriod: () => { + if (!hasPreviousPage) return; + setPageIndex(currentPageIndex - 1); + }, + }), + [hasNextPage, hasPreviousPage, currentPageIndex, setPageIndex] + ); +} diff --git a/apps/web/modules/bookings/hooks/useNearestFutureBooking.ts b/apps/web/modules/bookings/hooks/useNearestFutureBooking.ts new file mode 100644 index 0000000000..d19106a331 --- /dev/null +++ b/apps/web/modules/bookings/hooks/useNearestFutureBooking.ts @@ -0,0 +1,54 @@ +import type { Dayjs } from "@calcom/dayjs"; +import { trpc } from "@calcom/trpc/react"; + +import { NAVIGATION_PROBE_WINDOW_MONTHS } from "../lib/constants"; +import type { BookingListingStatus } from "../types"; + +interface UseNearestFutureBookingProps { + currentWeekStart: Dayjs; + filters: { + statuses: BookingListingStatus[]; + userIds?: number[]; + }; + enabled?: boolean; +} + +/** + * Probe hook to find the nearest future booking after the current week. + * Used for calendar view navigation to determine if there are any bookings + * in the future and to jump directly to the week containing that booking. + * + * Uses a broad date range (NAVIGATION_PROBE_WINDOW_MONTHS) but only fetches + * 1 booking (the nearest one) to minimize data transfer. + */ +export function useNearestFutureBooking({ + currentWeekStart, + filters, + enabled = true, +}: UseNearestFutureBookingProps) { + // Search from the end of current week to NAVIGATION_PROBE_WINDOW_MONTHS months ahead + const afterDate = currentWeekStart.add(1, "week").startOf("day"); + const beforeDate = currentWeekStart.add(NAVIGATION_PROBE_WINDOW_MONTHS, "month").endOf("day"); + + const query = trpc.viewer.bookings.get.useQuery( + { + filters: { + statuses: filters.statuses, + userIds: filters.userIds, + afterStartDate: afterDate.toISOString(), + beforeEndDate: beforeDate.toISOString(), + }, + limit: 1, // Only need the nearest one + // Default sort for "upcoming" status is ASC, which gives us the nearest future booking + }, + { + enabled, + staleTime: 5 * 60 * 1000, // 5 minutes + } + ); + + return { + nearestBooking: query.data?.bookings[0] ?? null, + isLoading: query.isLoading, + }; +} diff --git a/apps/web/modules/bookings/hooks/useNearestPastBooking.ts b/apps/web/modules/bookings/hooks/useNearestPastBooking.ts new file mode 100644 index 0000000000..62b05a7533 --- /dev/null +++ b/apps/web/modules/bookings/hooks/useNearestPastBooking.ts @@ -0,0 +1,55 @@ +import type { Dayjs } from "@calcom/dayjs"; +import { trpc } from "@calcom/trpc/react"; + +import { NAVIGATION_PROBE_WINDOW_MONTHS } from "../lib/constants"; +import type { BookingListingStatus } from "../types"; + +interface UseNearestPastBookingProps { + currentWeekStart: Dayjs; + filters: { + statuses: BookingListingStatus[]; + userIds?: number[]; + }; + enabled?: boolean; +} + +/** + * Probe hook to find the nearest past booking before the current week. + * Used for calendar view navigation to determine if there are any bookings + * in the past and to jump directly to the week containing that booking. + * + * Uses a broad date range (NAVIGATION_PROBE_WINDOW_MONTHS) but only fetches + * 1 booking (the nearest one) to minimize data transfer. + * Uses descending sort to get the closest past booking first. + */ +export function useNearestPastBooking({ + currentWeekStart, + filters, + enabled = true, +}: UseNearestPastBookingProps) { + // Search from NAVIGATION_PROBE_WINDOW_MONTHS months ago to the start of current week + const afterDate = currentWeekStart.subtract(NAVIGATION_PROBE_WINDOW_MONTHS, "month").startOf("day"); + const beforeDate = currentWeekStart.startOf("day"); + + const query = trpc.viewer.bookings.get.useQuery( + { + filters: { + statuses: filters.statuses, + userIds: filters.userIds, + afterStartDate: afterDate.toISOString(), + beforeEndDate: beforeDate.toISOString(), + }, + limit: 1, // Only need the nearest one + sort: { sortStart: "desc" }, // Get the closest past booking first + }, + { + enabled, + staleTime: 5 * 60 * 1000, // 5 minutes + } + ); + + return { + nearestBooking: query.data?.bookings[0] ?? null, + isLoading: query.isLoading, + }; +} diff --git a/apps/web/modules/bookings/lib/constants.ts b/apps/web/modules/bookings/lib/constants.ts new file mode 100644 index 0000000000..d8f875a75c --- /dev/null +++ b/apps/web/modules/bookings/lib/constants.ts @@ -0,0 +1,6 @@ +/** + * Number of months to look ahead/behind when probing for nearest bookings + * in calendar view navigation. This determines how far the navigation + * probe queries will search for bookings. + */ +export const NAVIGATION_PROBE_WINDOW_MONTHS = 3; diff --git a/apps/web/modules/bookings/lib/viewParser.ts b/apps/web/modules/bookings/lib/viewParser.ts deleted file mode 100644 index b6b2569f0e..0000000000 --- a/apps/web/modules/bookings/lib/viewParser.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createParser } from "nuqs"; - -export const viewParser = createParser({ - parse: (value: string) => { - if (value === "calendar") return "calendar"; - return "list"; - }, - serialize: (value: "list" | "calendar") => value, -}); - -export type BookingView = "list" | "calendar"; diff --git a/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx b/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx index 3997159ec3..21e5bed8fc 100644 --- a/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx +++ b/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx @@ -1,27 +1,75 @@ "use client"; -import React from "react"; +import React, { useEffect, useRef, useState } from "react"; import { createStore, useStore } from "zustand"; import { useSelectedBookingUid } from "../hooks/useSelectedBookingUid"; import type { BookingOutput } from "../types"; +export type PendingSelectionType = "first" | "last" | null; + +/** + * Capabilities interface for view-specific navigation logic. + * This allows different views (list/calendar) to provide their own + * navigation implementations without the core store knowing about them. + */ +export interface NavigationCapabilities { + /** + * Check if navigation to the previous period is possible + * (e.g., previous page in list view, previous week in calendar view) + */ + canNavigateToPreviousPeriod: () => boolean; + + /** + * Check if navigation to the next period is possible + * (e.g., next page in list view, next week in calendar view) + */ + canNavigateToNextPeriod: () => boolean; + + /** + * Request navigation to the previous period. + * This should trigger the view state update (e.g., page index change). + * The parent component will handle fetching data and updating bookings. + */ + requestPreviousPeriod: () => void; + + /** + * Request navigation to the next period. + * This should trigger the view state update (e.g., page index change). + * The parent component will handle fetching data and updating bookings. + */ + requestNextPeriod: () => void; +} + interface BookingDetailsSheetStore { - // State + // Core state (view-agnostic) selectedBookingUid: string | null; bookings: BookingOutput[]; + isTransitioning: boolean; + pendingSelection: PendingSelectionType; - // Actions + // Injected capabilities (provided by adapters) + capabilities: NavigationCapabilities | null; + + // Core actions setSelectedBookingUid: (uid: string | null) => void; setBookings: (bookings: BookingOutput[]) => void; + setCapabilities: (capabilities: NavigationCapabilities | null) => void; clearSelection: () => void; + clearPendingSelection: () => void; + setIsTransitioning: (isTransitioning: boolean) => void; - // Computed getters (used via selectors) + // Navigation methods (delegates to capabilities) + navigateNext: () => Promise; + navigatePrevious: () => Promise; + + // Simple getters (no view-specific logic) getSelectedBooking: () => BookingOutput | null; - getNextBookingUid: () => string | null; - getPreviousBookingUid: () => string | null; - hasNext: () => boolean; - hasPrevious: () => boolean; + getCurrentIndex: () => number; + hasNextInArray: () => boolean; + hasPreviousInArray: () => boolean; + isFirstInArray: () => boolean; + isLastInArray: () => boolean; } type BookingDetailsSheetStoreType = ReturnType; @@ -31,53 +79,90 @@ const createBookingDetailsSheetStore = (initialBookings: BookingOutput[] = []) = // Initial state selectedBookingUid: null, bookings: initialBookings, + isTransitioning: false, + pendingSelection: null, + capabilities: null, // Actions - setSelectedBookingUid: (uid) => set({ selectedBookingUid: uid }), + setSelectedBookingUid: (uid) => { + set({ selectedBookingUid: uid }); + }, setBookings: (bookings) => set({ bookings }), + setCapabilities: (capabilities) => set({ capabilities }), clearSelection: () => set({ selectedBookingUid: null }), + clearPendingSelection: () => set({ pendingSelection: null }), + setIsTransitioning: (isTransitioning) => set({ isTransitioning }), - // Computed getters + // Core getters getSelectedBooking: () => { const state = get(); if (!state.selectedBookingUid) return null; return state.bookings.find((booking) => booking.uid === state.selectedBookingUid) ?? null; }, - getNextBookingUid: () => { - const state = get(); - if (!state.selectedBookingUid) return null; - - const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid); - if (currentIndex === -1 || currentIndex >= state.bookings.length - 1) return null; - - return state.bookings[currentIndex + 1].uid; + getCurrentIndex: () => { + const { bookings, selectedBookingUid } = get(); + if (!selectedBookingUid) return -1; + return bookings.findIndex((b) => b.uid === selectedBookingUid); }, - getPreviousBookingUid: () => { - const state = get(); - if (!state.selectedBookingUid) return null; - - const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid); - if (currentIndex <= 0) return null; - - return state.bookings[currentIndex - 1].uid; + hasNextInArray: () => { + const { bookings, getCurrentIndex } = get(); + const index = getCurrentIndex(); + return index >= 0 && index < bookings.length - 1; }, - hasNext: () => { - const state = get(); - if (!state.selectedBookingUid) return false; - - const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid); - return currentIndex >= 0 && currentIndex < state.bookings.length - 1; + hasPreviousInArray: () => { + const { getCurrentIndex } = get(); + return getCurrentIndex() > 0; }, - hasPrevious: () => { - const state = get(); - if (!state.selectedBookingUid) return false; + isFirstInArray: () => get().getCurrentIndex() === 0, - const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid); - return currentIndex > 0; + isLastInArray: () => { + const { bookings, getCurrentIndex } = get(); + return getCurrentIndex() === bookings.length - 1; + }, + + // Navigation methods + navigateNext: async () => { + const state = get(); + + // Try navigating within current array first + if (state.hasNextInArray()) { + const nextIndex = state.getCurrentIndex() + 1; + set({ selectedBookingUid: state.bookings[nextIndex].uid }); + return; + } + + // Need to navigate to next period + if (!state.capabilities?.canNavigateToNextPeriod()) return; + + // Set pending selection to "first" and mark as transitioning + set({ isTransitioning: true, pendingSelection: "first" }); + // Trigger page/week change synchronously - the parent component will handle the data fetch + state.capabilities.requestNextPeriod(); + }, + + navigatePrevious: async () => { + const state = get(); + + // Try navigating within current array first + if (state.hasPreviousInArray()) { + const prevIndex = state.getCurrentIndex() - 1; + set({ selectedBookingUid: state.bookings[prevIndex].uid }); + return; + } + + // Need to navigate to previous period + if (!state.capabilities?.canNavigateToPreviousPeriod()) { + return; + } + + // Set pending selection to "last" and mark as transitioning + set({ isTransitioning: true, pendingSelection: "last" }); + // Trigger page/week change synchronously - the parent component will handle the data fetch + state.capabilities.requestPreviousPeriod(); }, })); }; @@ -87,20 +172,34 @@ const BookingDetailsSheetStoreContext = React.createContext createBookingDetailsSheetStore(bookings)); + const [store] = useState(() => createBookingDetailsSheetStore(bookings)); const [selectedBookingUidFromUrl, setSelectedBookingUidToUrl] = useSelectedBookingUid(); + const previousBookingsRef = useRef(bookings); + + // Update bookings in store + useEffect(() => { + const previousBookings = previousBookingsRef.current; + const hasBookingsChanged = bookings !== previousBookings; + + if (!hasBookingsChanged) return; - // Update bookings when they change - React.useEffect(() => { store.getState().setBookings(bookings); + previousBookingsRef.current = bookings; }, [bookings, store]); + // Update capabilities when they change + useEffect(() => { + store.getState().setCapabilities(capabilities ?? null); + }, [capabilities, store]); + // Sync Store → URL - React.useEffect(() => { + useEffect(() => { const unsubscribe = store.subscribe((state) => { const storeUid = state.selectedBookingUid; if (storeUid !== selectedBookingUidFromUrl) { @@ -112,7 +211,7 @@ export function BookingDetailsSheetStoreProvider({ }, [selectedBookingUidFromUrl, setSelectedBookingUidToUrl, store]); // Sync URL → Store - React.useEffect(() => { + useEffect(() => { const currentStoreUid = store.getState().selectedBookingUid; if (currentStoreUid !== selectedBookingUidFromUrl) { store.getState().setSelectedBookingUid(selectedBookingUidFromUrl); diff --git a/apps/web/modules/bookings/views/bookings-view.tsx b/apps/web/modules/bookings/views/bookings-view.tsx index 9fc1a693bb..a6b434cbd3 100644 --- a/apps/web/modules/bookings/views/bookings-view.tsx +++ b/apps/web/modules/bookings/views/bookings-view.tsx @@ -2,7 +2,6 @@ import dynamic from "next/dynamic"; import { usePathname } from "next/navigation"; -import { useQueryState } from "nuqs"; import { useMemo } from "react"; import { DataTableProvider, type SystemFilterSegment, ColumnFilterType } from "@calcom/features/data-table"; @@ -11,8 +10,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import classNames from "@calcom/ui/classNames"; import { BookingListContainer } from "../components/BookingListContainer"; +import { useBookingsView } from "../hooks/useBookingsView"; import type { validStatuses } from "../lib/validStatuses"; -import { viewParser } from "../lib/viewParser"; const BookingCalendarContainer = dynamic(() => import("../components/BookingCalendarContainer").then((mod) => ({ @@ -69,9 +68,7 @@ export default function Bookings(props: BookingsProps) { } function BookingsContent({ status, permissions, bookingsV3Enabled }: BookingsProps) { - const [_view] = useQueryState("view", viewParser.withDefault("list")); - // Force view to be "list" if calendar view is disabled - const view = bookingsV3Enabled ? _view : "list"; + const [view] = useBookingsView({ bookingsV3Enabled }); return (
@@ -83,7 +80,11 @@ function BookingsContent({ status, permissions, bookingsV3Enabled }: BookingsPro /> )} {bookingsV3Enabled && view === "calendar" && ( - + )}
); diff --git a/packages/features/bookings/services/BookingDetailsService.ts b/packages/features/bookings/services/BookingDetailsService.ts index b4b55bfbb4..00c6652c1a 100644 --- a/packages/features/bookings/services/BookingDetailsService.ts +++ b/packages/features/bookings/services/BookingDetailsService.ts @@ -43,6 +43,7 @@ export class BookingDetailsService { return { rescheduledToBooking, previousBooking, + tracking: booking.tracking, }; } } diff --git a/packages/features/calendars/weeklyview/components/Calendar.tsx b/packages/features/calendars/weeklyview/components/Calendar.tsx index 339d83cddc..a957681ebf 100644 --- a/packages/features/calendars/weeklyview/components/Calendar.tsx +++ b/packages/features/calendars/weeklyview/components/Calendar.tsx @@ -67,7 +67,7 @@ function CalendarInner(props: CalendarComponentProps) { className="bg-default dark:bg-cal-muted relative isolate flex h-full flex-auto flex-col">
+ className="flex max-w-full flex-none flex-col sm:max-w-none md:max-w-full">
({ startHour: state.startHour, events: state.events, eventOnClick: state.onEventClick, + selectedBookingUid: state.selectedBookingUid, }), shallow ); @@ -60,6 +61,29 @@ export function EventList({ day }: Props) { const hoveredEventLayout = hoveredEventId ? layoutMap.get(hoveredEventId) : null; const hoveredGroupIndex = hoveredEventLayout?.groupIndex ?? null; + // Find the event ID that matches the selected booking UID (only for events on this day) + const selectedEventId = useMemo(() => { + if (!selectedBookingUid) return undefined; + const matchingEvent = dayEvents.find((event) => event.options?.bookingUid === selectedBookingUid); + return matchingEvent?.id; + }, [dayEvents, selectedBookingUid]); + + // Scroll to the selected event when it changes + useEffect(() => { + if (selectedEventId === undefined) return; + + // Use requestAnimationFrame to ensure the DOM has updated + requestAnimationFrame(() => { + const eventElement = document.querySelector(`[data-calendar-event-id="${selectedEventId}"]`); + if (eventElement) { + eventElement.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + } + }); + }, [selectedEventId]); + return ( <> {dayEvents.map((event) => { @@ -72,8 +96,9 @@ export function EventList({ day }: Props) { const { eventStart, eventDuration, eventStartDiff } = calc; const isHovered = hoveredEventId === event.id; + const isSelected = selectedEventId === event.id; const isInHoveredGroup = hoveredGroupIndex !== null && layout.groupIndex === hoveredGroupIndex; - const zIndex = isHovered ? 100 : layout.baseZIndex; + const zIndex = isHovered || isSelected ? 79 : layout.baseZIndex; return (
setHoveredEventId(event.id)} onMouseLeave={() => setHoveredEventId(null)} style={{ @@ -91,7 +117,7 @@ export function EventList({ day }: Props) { zIndex, top: `calc(${eventStartDiff}*var(--one-minute-height))`, height: `max(15px, calc(${eventDuration}*var(--one-minute-height)))`, - transform: isHovered ? "scale(1.02)" : "scale(1)", + transform: isHovered || isSelected ? "scale(1.02)" : "scale(1)", opacity: hoveredGroupIndex !== null && !isHovered && isInHoveredGroup ? 0.6 : 1, }}>
); diff --git a/packages/features/calendars/weeklyview/state/store.ts b/packages/features/calendars/weeklyview/state/store.ts index 57bc2b0fb3..ff82ac6336 100644 --- a/packages/features/calendars/weeklyview/state/store.ts +++ b/packages/features/calendars/weeklyview/state/store.ts @@ -50,6 +50,7 @@ export function createCalendarStore(initial?: Partial): ...state, blockingDates, events, + selectedBookingUid: state.selectedBookingUid, }); }, setSelectedEvent: (event) => set({ selectedEvent: event }), diff --git a/packages/features/calendars/weeklyview/types/state.ts b/packages/features/calendars/weeklyview/types/state.ts index 572f038d71..1c504485ed 100644 --- a/packages/features/calendars/weeklyview/types/state.ts +++ b/packages/features/calendars/weeklyview/types/state.ts @@ -154,6 +154,10 @@ export type CalendarState = { * @default false */ showTimezone?: boolean; + /** + * Selected booking UID to highlight the corresponding event + */ + selectedBookingUid?: string | null; }; export type CalendarComponentProps = CalendarPublicActions & CalendarState & { isPending?: boolean }; diff --git a/packages/features/data-table/components/DataTableWrapper.tsx b/packages/features/data-table/components/DataTableWrapper.tsx index 433d3296e0..64135daf4e 100644 --- a/packages/features/data-table/components/DataTableWrapper.tsx +++ b/packages/features/data-table/components/DataTableWrapper.tsx @@ -105,6 +105,12 @@ export function DataTableWrapper({ })); }, [table, sorting, columnFilters, columnVisibility, setSorting, setColumnVisibility]); + // Scroll to top when table data changes + useScrollToTopOnDataChange({ + tableData: table.options.data, + paginationMode, + }); + let view: "loader" | "empty" | "error" | "table" = "table"; if (hasError && ErrorView) { view = "error"; @@ -170,3 +176,33 @@ export function DataTableWrapper({ ); } + +/** + * Scrolls to top when table data changes (pagination, filters, etc.) + * Only active for standard pagination mode where we show discrete pages + */ +function useScrollToTopOnDataChange({ + tableData, + paginationMode, +}: { + tableData: TData[]; + paginationMode: "standard" | "infinite"; +}) { + const previousDataRef = useRef(tableData); + const isInitialMount = useRef(true); + + useEffect(() => { + // Skip on initial mount + if (isInitialMount.current) { + isInitialMount.current = false; + return; + } + + const hasDataChanged = previousDataRef.current !== tableData; + previousDataRef.current = tableData; + + if (paginationMode === "standard" && hasDataChanged) { + window.scrollTo(0, 0); + } + }, [tableData, paginationMode]); +} diff --git a/packages/features/data-table/lib/parsers.ts b/packages/features/data-table/lib/parsers.ts index 6515c3de0b..8a607fce9d 100644 --- a/packages/features/data-table/lib/parsers.ts +++ b/packages/features/data-table/lib/parsers.ts @@ -1,5 +1,5 @@ import type { SortingState, VisibilityState, ColumnSizingState } from "@tanstack/react-table"; -import { parseAsArrayOf, parseAsJson, parseAsInteger, parseAsString } from "nuqs"; +import { parseAsArrayOf, parseAsJson, parseAsInteger, parseAsString, createParser } from "nuqs"; import { ZActiveFilter, ZSorting, ZColumnVisibility, ZColumnSizing } from "./types"; import type { ActiveFilters } from "./types"; @@ -20,5 +20,13 @@ export const columnVisibilityParser = parseAsJson(ZColumnVisibility.parse).withD export const columnSizingParser = parseAsJson(ZColumnSizing.parse).withDefault(DEFAULT_COLUMN_SIZING); export const segmentIdParser = parseAsString.withDefault(""); export const pageIndexParser = parseAsInteger.withDefault(0); -export const pageSizeParser = parseAsInteger.withDefault(DEFAULT_PAGE_SIZE); +// Custom parser that validates pageSize is positive to prevent division by zero +export const pageSizeParser = createParser({ + parse: (value) => { + const parsed = parseAsInteger.parse(value); + // Return null for invalid values (0 or negative), which will fall back to default + return parsed !== null && parsed > 0 ? parsed : null; + }, + serialize: (value) => String(value), +}).withDefault(DEFAULT_PAGE_SIZE); export const searchTermParser = parseAsString.withDefault(""); diff --git a/packages/prisma/selects/booking.ts b/packages/prisma/selects/booking.ts index a105ddc813..2b74e888af 100644 --- a/packages/prisma/selects/booking.ts +++ b/packages/prisma/selects/booking.ts @@ -46,4 +46,13 @@ export const bookingDetailsSelect = { uid: true, rescheduled: true, fromReschedule: true, + tracking: { + select: { + utm_source: true, + utm_medium: true, + utm_campaign: true, + utm_term: true, + utm_content: true, + }, + }, } satisfies Prisma.BookingSelect; diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts index 2ae77b2620..8f472df732 100644 --- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts @@ -65,6 +65,7 @@ export const getHandler = async ({ ctx, input }: GetOptions) => { take, skip, filters: input.filters, + sort: input.sort, }); // Generate next cursor for infinite query support diff --git a/packages/trpc/server/routers/viewer/bookings/get.schema.ts b/packages/trpc/server/routers/viewer/bookings/get.schema.ts index 3d2116c85d..487f334b44 100644 --- a/packages/trpc/server/routers/viewer/bookings/get.schema.ts +++ b/packages/trpc/server/routers/viewer/bookings/get.schema.ts @@ -24,6 +24,12 @@ export const ZGetInputSchema = z.object({ offset: z.number().default(0), // Cursor for infinite query support (calendar view) cursor: z.string().optional(), + // Sort options for controlling result order + sort: z + .object({ + sortStart: z.enum(["asc", "desc"]).optional(), + }) + .optional(), }); export type TGetInputSchema = z.infer;