Files
calendar/companion/hooks/useEventTypes.ts
T
Dhairyashil ShindeandGitHub 9d2fa89a5e feat(companion): add optimistic updates to EventTypeDetail screen 2 (#26945)
* 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.
2026-01-16 19:04:52 -03:00

355 lines
11 KiB
TypeScript

/**
* Event Types Query Hooks
*
* This module provides React Query hooks for fetching and mutating event types.
* It integrates with the existing CalComAPIService and provides:
* - Automatic caching with configurable stale times
* - Pull-to-refresh support via refetch
* - Optimistic updates for mutations
* - Cache invalidation on create/update/delete
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CACHE_CONFIG, queryKeys } from "@/config/cache.config";
import { CalComAPIService, type CreateEventTypeInput, type EventType } from "@/services/calcom";
/**
* Hook to fetch all event types
*
* @returns Query result with event types data, loading state, error, and refetch function
*
* @example
* ```tsx
* const { data: eventTypes, isLoading, refetch, isRefetching } = useEventTypes();
*
* // Pull-to-refresh
* <RefreshControl refreshing={isRefetching} onRefresh={refetch} />
* ```
*/
export function useEventTypes() {
return useQuery({
queryKey: queryKeys.eventTypes.lists(),
queryFn: () => CalComAPIService.getEventTypes(),
staleTime: CACHE_CONFIG.eventTypes.staleTime,
// Keep previous data while fetching new data (smoother UX)
placeholderData: (previousData) => previousData,
// Don't retry on network errors (keeps cache intact)
retry: (failureCount, error) => {
if (error?.message?.includes("Network") || error?.message?.includes("fetch")) {
return false;
}
return failureCount < 2;
},
refetchOnReconnect: true,
});
}
/**
* Hook to fetch a single event type by ID
*
* @param id - The ID of the event type
* @returns Query result with event type data
*
* @example
* ```tsx
* const { data: eventType, isLoading } = useEventTypeById(123);
* ```
*/
export function useEventTypeById(id: number | undefined) {
return useQuery({
queryKey: queryKeys.eventTypes.detail(id || 0),
queryFn: () => {
if (!id) throw new Error("id is required");
return CalComAPIService.getEventTypeById(id);
},
enabled: !!id, // Only fetch when id is provided
staleTime: CACHE_CONFIG.eventTypes.staleTime,
});
}
/**
* Hook to create a new event type
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: createEventType, isPending } = useCreateEventType();
*
* createEventType({
* title: 'Quick Chat',
* slug: 'quick-chat',
* lengthInMinutes: 15,
* });
* ```
*/
export function useCreateEventType() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateEventTypeInput) => CalComAPIService.createEventType(input),
onSuccess: (newEventType) => {
// Invalidate the list to include the new event type
queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.lists() });
// Optionally, add the new event type to cache immediately
queryClient.setQueryData(queryKeys.eventTypes.detail(newEventType.id), newEventType);
},
onError: (error) => {
console.error("Failed to create event type");
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[useCreateEventType] failed", { message, stack });
}
},
});
}
/**
* Hook to update an event type with optimistic updates
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: updateEventType, isPending } = useUpdateEventType();
*
* updateEventType({
* id: 123,
* updates: { title: 'Updated Title' }
* });
* ```
*/
export function useUpdateEventType() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, updates }: { id: number; updates: Record<string, unknown> }) =>
CalComAPIService.updateEventType(id, updates),
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() });
// Snapshot the previous values for rollback
const previousEventType = queryClient.getQueryData<EventType | null>(
queryKeys.eventTypes.detail(id)
);
const previousEventTypes = queryClient.getQueryData<EventType[]>(
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 };
},
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<EventType[]>(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);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[useUpdateEventType] failed", { message, stack });
}
},
});
}
/**
* Hook to delete an event type
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: deleteEventType, isPending } = useDeleteEventType();
*
* deleteEventType(123);
* ```
*/
export function useDeleteEventType() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => CalComAPIService.deleteEventType(id),
onMutate: async (deletedId) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: queryKeys.eventTypes.lists() });
// Snapshot the previous value
const previousEventTypes = queryClient.getQueryData<EventType[]>(
queryKeys.eventTypes.lists()
);
// Optimistically remove from the list
if (previousEventTypes) {
queryClient.setQueryData(
queryKeys.eventTypes.lists(),
previousEventTypes.filter((et) => et.id !== deletedId)
);
}
return { previousEventTypes };
},
onError: (error, _deletedId, context) => {
// Rollback on error
if (context?.previousEventTypes) {
queryClient.setQueryData(queryKeys.eventTypes.lists(), context.previousEventTypes);
}
console.error("Failed to delete event type");
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[useDeleteEventType] failed", { message, stack });
}
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.lists() });
},
});
}
/**
* Hook to duplicate an event type
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: duplicateEventType, isPending } = useDuplicateEventType();
*
* duplicateEventType({
* eventType: existingEventType,
* existingEventTypes: allEventTypes
* });
* ```
*/
export function useDuplicateEventType() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
eventType,
existingEventTypes,
}: {
eventType: EventType;
existingEventTypes: EventType[];
}) => {
// Generate a new title and slug for the duplicate
const newTitle = `${eventType.title} (copy)`;
let newSlug = `${eventType.slug}-copy`;
// Check if slug already exists and append a number if needed
let counter = 1;
while (existingEventTypes.some((et) => et.slug === newSlug)) {
newSlug = `${eventType.slug}-copy-${counter}`;
counter++;
}
const duration = eventType.lengthInMinutes ?? eventType.length ?? 15;
return CalComAPIService.createEventType({
title: newTitle,
slug: newSlug,
lengthInMinutes: duration,
description: eventType.description || undefined,
});
},
onSuccess: () => {
// Invalidate the list to include the duplicated event type
queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.lists() });
},
onError: (error) => {
console.error("Failed to duplicate event type");
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[useDuplicateEventType] failed", { message, stack });
}
},
});
}
/**
* Hook to prefetch event types (useful for navigation)
*
* @returns Function to prefetch event types
*/
export function usePrefetchEventTypes() {
const queryClient = useQueryClient();
return () => {
queryClient.prefetchQuery({
queryKey: queryKeys.eventTypes.lists(),
queryFn: () => CalComAPIService.getEventTypes(),
staleTime: CACHE_CONFIG.eventTypes.staleTime,
});
};
}
/**
* Hook to invalidate all event types cache
*
* @returns Function to invalidate event types cache
*/
export function useInvalidateEventTypes() {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.all });
};
}
/**
* Type exports for consumers
*/
export type { EventType, CreateEventTypeInput };