From 9d2fa89a5ed85e038f0599befb06a24286db719e Mon Sep 17 00:00:00 2001 From: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com> Date: Sat, 17 Jan 2026 03:34:52 +0530 Subject: [PATCH] feat(companion): add optimistic updates to EventTypeDetail screen 2 (#26945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(companion): add optimistic updates to EditAvailabilityNameScreen - Refactor useUpdateSchedule hook with optimistic updates - Update EditAvailabilityNameScreen to use mutation hook instead of direct API call - Update EditAvailabilityNameScreen.ios.tsx with same pattern - Cache is updated immediately on save, then synced with server - On error, cache is rolled back to previous state * fix(companion): fix cache update for schedule list when detail cache is empty - Update list cache in onMutate even when detail cache doesn't exist - Remove onSettled invalidation that was causing issues with staleTime: Infinity - Add fallback invalidation in onSuccess when list cache doesn't exist * fix(companion): fix AvailabilityDetailScreen not reflecting cache updates The previous optimistic updates implementation in EditAvailabilityNameScreen correctly updated the React Query cache, but AvailabilityDetailScreen was still using direct CalComAPIService.getScheduleById() calls with local useState, completely bypassing the cache. The disconnect: - EditAvailabilityNameScreen → useUpdateSchedule → Updates React Query Cache ✓ - AvailabilityDetailScreen → CalComAPIService.getScheduleById → Local state ✗ This meant when a user saved changes to a schedule's name/timezone, the cache was updated but the detail screen (and subsequently the list screen) never saw those updates because they weren't reading from the cache. Changes: - Refactor AvailabilityDetailScreen.tsx to use useScheduleById hook - Refactor AvailabilityDetailScreen.ios.tsx to use useScheduleById hook - Replace direct API calls with React Query for cache synchronization - Add RefreshControl for pull-to-refresh support - Use mutation hooks (useSetScheduleAsDefault, useDeleteSchedule) for actions - Derive availability/overrides data using useMemo from query result Now when EditAvailabilityNameScreen updates the cache, AvailabilityDetailScreen automatically reflects those changes because both read from the same cache. * feat(companion): refactor EditAvailabilityOverrideScreen to use mutation hook - Replace direct CalComAPIService.updateSchedule calls with useUpdateSchedule hook - Remove local isSaving state, use isPending from mutation hook instead - Cache is now updated automatically via the mutation hook's optimistic updates - Consistent pattern with EditAvailabilityNameScreen refactoring * feat(companion): refactor EditAvailabilityDayScreen to use mutation hook - Replace direct CalComAPIService.updateSchedule calls with useUpdateSchedule hook - Remove local isSaving state, use isPending from mutation hook instead - Cache is now updated automatically via the mutation hook's optimistic updates - Consistent pattern with EditAvailabilityNameScreen and EditAvailabilityOverrideScreen * fix(companion): refactor edit-availability-hours routes to use useScheduleById hook The working hours page (page 2 in the flow) was showing stale data because it used direct CalComAPIService.getScheduleById() calls with local useState, bypassing the React Query cache. The disconnect: - EditAvailabilityDayScreen → useUpdateSchedule → Updates React Query Cache ✓ - edit-availability-hours route → CalComAPIService.getScheduleById → Local state ✗ This meant when a user saved changes to a day's availability, the cache was updated but the working hours page never saw those updates because it wasn't reading from the cache. Changes: - Refactor edit-availability-hours.tsx to use useScheduleById hook - Refactor edit-availability-hours.ios.tsx to use useScheduleById hook - Replace direct API calls with React Query for cache synchronization - Now all 3 pages in the flow read from the same cache * fix(companion): make Date Overrides section clickable when empty Previously, when there were no date overrides, the Date Overrides section was just a plain View without any press handler, making it impossible for users to navigate to the edit override page to add new overrides. This follows the same pattern as other sections (Weekly Schedule, Timezone) which are always clickable regardless of their content state. Changes: - Wrap the 'No Overrides' section in AppPressable with navigation handler - Add chevron-forward icon to indicate it's tappable - Apply fix to both AvailabilityDetailScreen.tsx and .ios.tsx * fix(companion): use useEffect instead of useMemo for side effects - Change useMemo to useEffect for onActionsReady callback (side effect) - Move error handling (showErrorAlert, router.back) to useEffect - Keep early return for error state after useEffect hooks Fixes Cubic AI review feedback (confidence 9/10) * feat(companion): add optimistic updates to EventTypeDetail screen - Enhance useUpdateEventType hook with full optimistic update support - Add onMutate callback to update cache immediately - Add onSuccess callback to sync with server response - Add onError callback to rollback on failure - Update both eventTypes.detail(id) and eventTypes.lists() caches - Refactor handleSave in event-type-detail.tsx - Replace direct CalComAPIService.updateEventType() with useUpdateEventType hook - Replace direct CalComAPIService.createEventType() with useCreateEventType hook - Remove manual fetchEventTypeData() call after save (cache updates automatically) - Replace local saving state with mutation hooks' isPending states - Refactor handleDelete in event-type-detail.tsx - Replace direct CalComAPIService.deleteEventType() with useDeleteEventType hook This ensures the event types list updates immediately after save/delete without requiring manual refresh. --- .../(event-types)/event-type-detail.tsx | 36 ++++----- companion/hooks/useEventTypes.ts | 73 +++++++++++++++++-- 2 files changed, 83 insertions(+), 26 deletions(-) diff --git a/companion/app/(tabs)/(event-types)/event-type-detail.tsx b/companion/app/(tabs)/(event-types)/event-type-detail.tsx index 78552bdfa2..6130b535bd 100644 --- a/companion/app/(tabs)/(event-types)/event-type-detail.tsx +++ b/companion/app/(tabs)/(event-types)/event-type-detail.tsx @@ -34,6 +34,7 @@ import { type EventType, type Schedule, } from "@/services/calcom"; +import { useCreateEventType, useDeleteEventType, useUpdateEventType } from "@/hooks"; import type { LocationItem, LocationOptionGroup } from "@/types/locations"; import { showErrorAlert, showInfoAlert, showSuccessAlert } from "@/utils/alerts"; import { openInAppBrowser } from "@/utils/browser"; @@ -140,6 +141,11 @@ export default function EventTypeDetail() { slug?: string; }>(); + // Mutation hooks for optimistic updates + const { mutateAsync: updateEventType, isPending: isUpdating } = useUpdateEventType(); + const { mutateAsync: createEventType, isPending: isCreating } = useCreateEventType(); + const { mutateAsync: deleteEventType } = useDeleteEventType(); + const [activeTab, setActiveTab] = useState("basics"); // Form state @@ -178,7 +184,8 @@ export default function EventTypeDetail() { const [conferencingLoading, setConferencingLoading] = useState(false); const [eventTypeData, setEventTypeData] = useState(null); const [bookingUrl, setBookingUrl] = useState(""); - const [saving, setSaving] = useState(false); + // Use mutation hooks' isPending states instead of local saving state + const isSaving = isUpdating || isCreating; const [beforeEventBuffer, setBeforeEventBuffer] = useState("No buffer time"); const [afterEventBuffer, setAfterEventBuffer] = useState("No buffer time"); const [showBeforeBufferDropdown, setShowBeforeBufferDropdown] = useState(false); @@ -989,7 +996,7 @@ export default function EventTypeDetail() { } try { - await CalComAPIService.deleteEventType(eventTypeId); + await deleteEventType(eventTypeId); showSuccessAlert("Success", "Event type deleted successfully"); router.back(); @@ -1202,8 +1209,6 @@ export default function EventTypeDetail() { // Extract values with optional chaining outside try/catch for React Compiler const selectedScheduleId = selectedSchedule?.id; - setSaving(true); - if (isCreateMode) { // For CREATE mode, build full payload const payload: CreateEventTypePayload = { @@ -1226,18 +1231,16 @@ export default function EventTypeDetail() { payload.hidden = isHidden; - // Create new event type + // Create new event type using mutation hook try { - await CalComAPIService.createEventType(payload); + await createEventType(payload); } catch (error) { safeLogError("Failed to save event type:", error); showErrorAlert("Error", "Failed to create event type. Please try again."); - setSaving(false); return; } showSuccessAlert("Success", "Event type created successfully"); router.back(); - setSaving(false); } else { // For UPDATE mode, use partial update - only send changed fields // Using the memoized currentFormState @@ -1246,22 +1249,19 @@ export default function EventTypeDetail() { if (Object.keys(payload).length === 0) { // This should theoretically strictly not be reached if button is disabled, // but it acts as a safeguard. - // setSaving(false); // return; } + // Update event type using mutation hook with optimistic updates try { - await CalComAPIService.updateEventType(parseInt(id, 10), payload); + await updateEventType({ id: parseInt(id, 10), updates: payload }); } catch (error) { safeLogError("Failed to save event type:", error); showErrorAlert("Error", "Failed to update event type. Please try again."); - setSaving(false); return; } showSuccessAlert("Success", "Event type updated successfully"); - // Refresh event type data to sync with server - await fetchEventTypeData(); - setSaving(false); + // No need to manually refresh - cache is updated by the mutation hook } }; @@ -1331,12 +1331,12 @@ export default function EventTypeDetail() { {/* Save Button */} {saveButtonText} @@ -1396,7 +1396,7 @@ export default function EventTypeDetail() { diff --git a/companion/hooks/useEventTypes.ts b/companion/hooks/useEventTypes.ts index b97e303409..f06a0da838 100644 --- a/companion/hooks/useEventTypes.ts +++ b/companion/hooks/useEventTypes.ts @@ -107,7 +107,7 @@ export function useCreateEventType() { } /** - * Hook to update an event type + * Hook to update an event type with optimistic updates * * @returns Mutation function and state * @@ -125,16 +125,73 @@ export function useUpdateEventType() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, updates }: { id: number; updates: Partial }) => + mutationFn: ({ id, updates }: { id: number; updates: Record }) => CalComAPIService.updateEventType(id, updates), - onSuccess: (updatedEventType, variables) => { - // Invalidate the list - queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.lists() }); + onMutate: async ({ id, updates }) => { + // Cancel any outgoing refetches to prevent overwriting optimistic update + await queryClient.cancelQueries({ queryKey: queryKeys.eventTypes.detail(id) }); + await queryClient.cancelQueries({ queryKey: queryKeys.eventTypes.lists() }); - // Update the specific event type in cache - queryClient.setQueryData(queryKeys.eventTypes.detail(variables.id), updatedEventType); + // Snapshot the previous values for rollback + const previousEventType = queryClient.getQueryData( + queryKeys.eventTypes.detail(id) + ); + const previousEventTypes = queryClient.getQueryData( + queryKeys.eventTypes.lists() + ); + + // Optimistically update the detail cache if it exists + if (previousEventType) { + const optimisticEventType: EventType = { + ...previousEventType, + ...updates, + }; + queryClient.setQueryData(queryKeys.eventTypes.detail(id), optimisticEventType); + } + + // Update the list cache optimistically (even if detail cache doesn't exist) + if (previousEventTypes) { + const updatedList = previousEventTypes.map((et) => { + if (et.id === id) { + return { + ...et, + ...updates, + }; + } + return et; + }); + queryClient.setQueryData(queryKeys.eventTypes.lists(), updatedList); + } + + return { previousEventType, previousEventTypes }; }, - onError: (error) => { + onSuccess: (updatedEventType, variables) => { + // Update the specific event type in cache with server response + queryClient.setQueryData(queryKeys.eventTypes.detail(variables.id), updatedEventType); + + // Update the list cache with the server response + const currentEventTypes = queryClient.getQueryData(queryKeys.eventTypes.lists()); + if (currentEventTypes) { + const updatedList = currentEventTypes.map((et) => + et.id === variables.id ? updatedEventType : et + ); + queryClient.setQueryData(queryKeys.eventTypes.lists(), updatedList); + } else { + // If list cache doesn't exist, invalidate to trigger refetch when user navigates to list + queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.lists() }); + } + }, + onError: (error, variables, context) => { + // Rollback to previous values on error + if (context?.previousEventType) { + queryClient.setQueryData( + queryKeys.eventTypes.detail(variables.id), + context.previousEventType + ); + } + if (context?.previousEventTypes) { + queryClient.setQueryData(queryKeys.eventTypes.lists(), context.previousEventTypes); + } console.error("Failed to update event type"); if (__DEV__) { const message = error instanceof Error ? error.message : String(error);