Files
calendar/apps/web/modules/bookings/hooks/useBookingsView.ts
T
Eunjae LeeGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>sean-brydon
52d5261b51 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>
2025-12-15 16:04:00 +00:00

105 lines
3.1 KiB
TypeScript

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;
}