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:
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
@@ -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}>
|
||||
"{booking.description}"
|
||||
</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]);
|
||||
}
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user