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 <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* 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>
This commit is contained in:
Eunjae Lee
2025-12-15 16:04:00 +00:00
committed by GitHub
co-authored by eunjae@cal.com <hey@eunjae.dev> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> sean-brydon
parent 53776d28e6
commit 52d5261b51
30 changed files with 997 additions and 141 deletions
@@ -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 (
@@ -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<HTMLDivElement>(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 (
<div
ref={itemRef}
data-testid="booking-item"
data-today={String(booking.isToday)}
data-booking-list-item="true"
className="hover:bg-cal-muted group w-full">
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"
)}>
<div className="flex flex-col sm:flex-row">
<div className="sm:min-w-48 hidden align-top ltr:pl-3 rtl:pr-6 sm:table-cell">
<div className="flex h-full items-center">
@@ -381,7 +405,7 @@ function BookingListItem(booking: BookingItemProps) {
<div
title={title}
className={classNames(
"max-w-10/12 sm:max-w-56 text-emphasis break-words text-sm font-medium leading-6 md:max-w-full",
"max-w-10/12 text-emphasis sm:max-w-56 break-words text-sm font-medium leading-6 md:max-w-full",
isCancelled ? "line-through" : ""
)}>
{title}
@@ -395,7 +419,7 @@ function BookingListItem(booking: BookingItemProps) {
</div>
{booking.description && (
<div
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 text-default truncate text-sm"
className="max-w-10/12 text-default sm:max-w-32 md:max-w-52 xl:max-w-80 truncate text-sm"
title={booking.description}>
&quot;{booking.description}&quot;
</div>
@@ -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<RowData, { type: "data" }> => 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<RowData>({
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<RowData, { type: "data" }> => row.type === "data")
.map((row) => row.booking);
}, [rowData]);
return (
<>
<div className="mb-4 flex items-center justify-between">
@@ -132,7 +145,7 @@ function BookingCalendarInner({
<Icon name="chevron-right" className="h-4 w-4" />
</Button>
</ButtonGroup>
<ViewToggleButton />
<ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />
</div>
</div>
{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 (
<BookingDetailsSheetStoreProvider bookings={bookings}>
<BookingDetailsSheetStoreProvider bookings={bookings} capabilities={capabilities}>
<BookingCalendarInner
{...props}
data={data}
@@ -217,6 +240,9 @@ export function BookingCalendarContainer(props: BookingCalendarContainerProps) {
isPending={query.isPending}
hasError={!!query.error}
errorMessage={query.error?.message}
hasNextPage={hasNextPage}
isFetched={isFetched}
isFetchingNextPage={isFetchingNextPage}
/>
</BookingDetailsSheetStoreProvider>
);
@@ -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) {
@@ -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}
/>
<TrackingSection tracking={bookingDetails?.tracking} />
</div>
</SheetBody>
@@ -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 (
<Section title={t("utm_params")}>
<div className="text-default text-sm">
{utmEntries.map(([key, value]) => (
<div key={key} className="mb-1 last:mb-0">
<span className="font-medium">{key}</span>:{" "}
<code className="bg-subtle text-default rounded px-1 py-0.5 font-mono text-xs">{value}</code>
</div>
))}
</div>
</Section>
);
}
function Section({
title,
className,
@@ -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 ? (
<Alert severity="error" title={t("something_went_wrong")} message={errorMessage} />
) : undefined;
@@ -180,7 +187,7 @@ function BookingListInner({
<div className="hidden grow md:block" />
<DataTableSegment.Select shortLabel />
{bookingsV3Enabled && <ViewToggleButton />}
{bookingsV3Enabled && <ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />}
</div>
{displayedFilterCount > 0 && showFilters && (
<div className="mt-3 flex flex-wrap items-center gap-2">
@@ -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 (
<BookingDetailsSheetStoreProvider bookings={bookings}>
<BookingDetailsSheetStoreProvider bookings={bookings} capabilities={capabilities}>
<BookingListInner
{...props}
data={query.data}
@@ -260,6 +291,7 @@ export function BookingListContainer(props: BookingListContainerProps) {
hasError={!!query.error}
errorMessage={query.error?.message}
totalRowCount={query.data?.totalCount}
bookings={bookings}
/>
</BookingDetailsSheetStoreProvider>
);
@@ -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() {
<div className="hidden sm:block">
<ToggleGroup
value={view}
onValueChange={(value) => {
onValueChange={(value: "list" | "calendar") => {
if (!value) return;
setView(value as BookingView);
setView(value);
}}
options={[
{
@@ -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<string, NonNullable<typeof data>["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<BookingRowData[]>(() => {
@@ -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<RowData[]>(() => {
@@ -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;
}
@@ -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,
]);
}
@@ -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]
);
}
@@ -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]);
}
@@ -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<typeof trpc.viewer.bookings.get.useQuery>[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]
);
}
@@ -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,
};
}
@@ -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,
};
}
@@ -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;
@@ -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";
@@ -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<void>;
navigatePrevious: () => Promise<void>;
// 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<typeof createBookingDetailsSheetStore>;
@@ -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<BookingDetailsSheetS
export function BookingDetailsSheetStoreProvider({
children,
bookings,
capabilities,
}: {
children: React.ReactNode;
bookings: BookingOutput[];
capabilities?: NavigationCapabilities | null;
}) {
const [store] = React.useState(() => createBookingDetailsSheetStore(bookings));
const [store] = useState(() => createBookingDetailsSheetStore(bookings));
const [selectedBookingUidFromUrl, setSelectedBookingUidToUrl] = useSelectedBookingUid();
const previousBookingsRef = useRef<BookingOutput[]>(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);
@@ -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 (
<div className={classNames(view === "calendar" && "-mb-8")}>
@@ -83,7 +80,11 @@ function BookingsContent({ status, permissions, bookingsV3Enabled }: BookingsPro
/>
)}
{bookingsV3Enabled && view === "calendar" && (
<BookingCalendarContainer status={status} permissions={permissions} />
<BookingCalendarContainer
status={status}
permissions={permissions}
bookingsV3Enabled={bookingsV3Enabled}
/>
)}
</div>
);
@@ -43,6 +43,7 @@ export class BookingDetailsService {
return {
rescheduledToBooking,
previousBooking,
tracking: booking.tracking,
};
}
}
@@ -67,7 +67,7 @@ function CalendarInner(props: CalendarComponentProps) {
className="bg-default dark:bg-cal-muted relative isolate flex h-full flex-auto flex-col">
<div
style={{ width: "165%" }}
className="flex h-full max-w-full flex-none flex-col sm:max-w-none md:max-w-full">
className="flex max-w-full flex-none flex-col sm:max-w-none md:max-w-full">
<DateValues
containerNavRef={containerNav}
days={days}
@@ -80,9 +80,9 @@ function CalendarInner(props: CalendarComponentProps) {
className={classNames(
"bg-default dark:bg-cal-muted ring-muted sticky left-0 z-10 w-16 flex-none ring-1",
showBorder &&
(borderColor === "subtle"
? "border-subtle border-l border-r"
: "border-default border-l border-r")
(borderColor === "subtle"
? "border-subtle border-l border-r"
: "border-default border-l border-r")
)}
/>
<div
@@ -91,10 +91,10 @@ function CalendarInner(props: CalendarComponentProps) {
showBackgroundPattern === false
? undefined
: {
backgroundColor: "var(--disabled-gradient-background)",
background:
"repeating-linear-gradient(-45deg, var(--disabled-gradient-background), var(--disabled-gradient-background) 2.5px, var(--disabled-gradient-foreground) 2.5px, var(--disabled-gradient-foreground) 5px)",
}
backgroundColor: "var(--disabled-gradient-background)",
background:
"repeating-linear-gradient(-45deg, var(--disabled-gradient-background), var(--disabled-gradient-background) 2.5px, var(--disabled-gradient-foreground) 2.5px, var(--disabled-gradient-foreground) 5px)",
}
}>
<HorizontalLines
hours={hours}
@@ -32,7 +32,7 @@ const eventClasses = cva(
false: "hover:cursor-pointer",
},
selected: {
true: "bg-inverted text-inverted border border-transparent",
true: "",
false: "",
},
borderOnly: {
@@ -120,7 +120,7 @@ export function Event({
borderOnly: options?.borderOnly ?? false,
}),
options?.className,
isHovered && "ring-brand-default shadow-lg ring-2 ring-offset-0"
(isHovered || selected) && "ring-brand-default shadow-lg ring-2 ring-offset-0"
)}
style={{
transition: "all 100ms ease-out",
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs";
@@ -13,11 +13,12 @@ type Props = {
};
export function EventList({ day }: Props) {
const { startHour, events, eventOnClick } = useCalendarStore(
const { startHour, events, eventOnClick, selectedBookingUid } = useCalendarStore(
(state) => ({
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 (
<div
@@ -83,6 +108,7 @@ export function EventList({ day }: Props) {
event.options?.borderOnly && "pointer-events-none"
)}
data-testid={event.options?.["data-test-id"]}
data-calendar-event-id={event.id}
onMouseEnter={() => 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,
}}>
<Event
@@ -99,6 +125,7 @@ export function EventList({ day }: Props) {
eventDuration={eventDuration}
onEventClick={eventOnClick}
isHovered={isHovered}
currentlySelectedEventId={selectedEventId}
/>
</div>
);
@@ -50,6 +50,7 @@ export function createCalendarStore(initial?: Partial<CalendarComponentProps>):
...state,
blockingDates,
events,
selectedBookingUid: state.selectedBookingUid,
});
},
setSelectedEvent: (event) => set({ selectedEvent: event }),
@@ -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 };
@@ -105,6 +105,12 @@ export function DataTableWrapper<TData>({
}));
}, [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<TData>({
</>
);
}
/**
* Scrolls to top when table data changes (pagination, filters, etc.)
* Only active for standard pagination mode where we show discrete pages
*/
function useScrollToTopOnDataChange<TData>({
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]);
}
+10 -2
View File
@@ -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("");
+9
View File
@@ -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;
@@ -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
@@ -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<typeof ZGetInputSchema>;