From ef0af95bb1dcc6a701036373c9da91c97661f9d3 Mon Sep 17 00:00:00 2001 From: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com> Date: Fri, 16 Jan 2026 23:38:52 +0530 Subject: [PATCH] feat(companion): add optimistic updates to availability edit screens (#26931) 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) --- companion/app/edit-availability-hours.ios.tsx | 27 +- companion/app/edit-availability-hours.tsx | 27 +- .../screens/AvailabilityDetailScreen.ios.tsx | 248 ++++++++---------- .../screens/AvailabilityDetailScreen.tsx | 248 ++++++++---------- .../screens/EditAvailabilityDayScreen.ios.tsx | 42 +-- .../screens/EditAvailabilityDayScreen.tsx | 40 +-- .../EditAvailabilityNameScreen.ios.tsx | 42 +-- .../screens/EditAvailabilityNameScreen.tsx | 42 +-- .../EditAvailabilityOverrideScreen.ios.tsx | 50 ++-- .../EditAvailabilityOverrideScreen.tsx | 56 ++-- companion/hooks/useSchedules.ts | 74 +++++- 11 files changed, 464 insertions(+), 432 deletions(-) diff --git a/companion/app/edit-availability-hours.ios.tsx b/companion/app/edit-availability-hours.ios.tsx index 3401359252..2865c7b084 100644 --- a/companion/app/edit-availability-hours.ios.tsx +++ b/companion/app/edit-availability-hours.ios.tsx @@ -1,11 +1,11 @@ import { osName } from "expo-device"; import { isLiquidGlassAvailable } from "expo-glass-effect"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect } from "react"; import { ActivityIndicator, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import EditAvailabilityHoursScreenComponent from "@/components/screens/EditAvailabilityHoursScreen.ios"; -import { CalComAPIService, type Schedule } from "@/services/calcom"; +import { useScheduleById } from "@/hooks/useSchedules"; import { showErrorAlert } from "@/utils/alerts"; // Semi-transparent background to prevent black flash while preserving glass effect @@ -22,25 +22,20 @@ export default function EditAvailabilityHoursIOS() { const { id } = useLocalSearchParams<{ id: string }>(); const router = useRouter(); const insets = useSafeAreaInsets(); - const [schedule, setSchedule] = useState(null); - const [isLoading, setIsLoading] = useState(true); + // Use React Query hook to read from cache (syncs with mutations) + const { data: schedule, isLoading, isError } = useScheduleById(id ? Number(id) : undefined); + + // Handle missing ID or error useEffect(() => { - if (id) { - setIsLoading(true); - CalComAPIService.getScheduleById(Number(id)) - .then(setSchedule) - .catch(() => { - showErrorAlert("Error", "Failed to load schedule details"); - router.back(); - }) - .finally(() => setIsLoading(false)); - } else { - setIsLoading(false); + if (!id) { showErrorAlert("Error", "Schedule ID is missing"); router.back(); + } else if (isError) { + showErrorAlert("Error", "Failed to load schedule details"); + router.back(); } - }, [id, router]); + }, [id, isError, router]); const handleDayPress = useCallback( (dayIndex: number) => { diff --git a/companion/app/edit-availability-hours.tsx b/companion/app/edit-availability-hours.tsx index 6b586c8fa2..ff194dcd79 100644 --- a/companion/app/edit-availability-hours.tsx +++ b/companion/app/edit-availability-hours.tsx @@ -1,36 +1,31 @@ import { Ionicons } from "@expo/vector-icons"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect } from "react"; import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper"; import EditAvailabilityHoursScreenComponent from "@/components/screens/EditAvailabilityHoursScreen"; -import { CalComAPIService, type Schedule } from "@/services/calcom"; +import { useScheduleById } from "@/hooks/useSchedules"; import { showErrorAlert } from "@/utils/alerts"; export default function EditAvailabilityHours() { const { id } = useLocalSearchParams<{ id: string }>(); const router = useRouter(); const insets = useSafeAreaInsets(); - const [schedule, setSchedule] = useState(null); - const [isLoading, setIsLoading] = useState(true); + // Use React Query hook to read from cache (syncs with mutations) + const { data: schedule, isLoading, isError } = useScheduleById(id ? Number(id) : undefined); + + // Handle missing ID or error useEffect(() => { - if (id) { - setIsLoading(true); - CalComAPIService.getScheduleById(Number(id)) - .then(setSchedule) - .catch(() => { - showErrorAlert("Error", "Failed to load schedule details"); - router.back(); - }) - .finally(() => setIsLoading(false)); - } else { - setIsLoading(false); + if (!id) { showErrorAlert("Error", "Schedule ID is missing"); router.back(); + } else if (isError) { + showErrorAlert("Error", "Failed to load schedule details"); + router.back(); } - }, [id, router]); + }, [id, isError, router]); const handleClose = useCallback(() => { router.back(); diff --git a/companion/components/screens/AvailabilityDetailScreen.ios.tsx b/companion/components/screens/AvailabilityDetailScreen.ios.tsx index ff2feb49a9..350e70900e 100644 --- a/companion/components/screens/AvailabilityDetailScreen.ios.tsx +++ b/companion/components/screens/AvailabilityDetailScreen.ios.tsx @@ -3,15 +3,16 @@ * * iOS-specific read-only display of availability schedule. * Editing is done via separate bottom sheet screens. + * Uses React Query for cache synchronization with edit screens. */ import { Ionicons } from "@expo/vector-icons"; import { useRouter } from "expo-router"; -import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react"; -import { ActivityIndicator, Alert, ScrollView, Text, View } from "react-native"; +import { forwardRef, useCallback, useImperativeHandle, useMemo } from "react"; +import { ActivityIndicator, Alert, RefreshControl, ScrollView, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppPressable } from "@/components/AppPressable"; -import { CalComAPIService, type Schedule } from "@/services/calcom"; +import { useDeleteSchedule, useScheduleById, useSetScheduleAsDefault } from "@/hooks/useSchedules"; import type { ScheduleAvailability } from "@/services/types"; import { showErrorAlert, showInfoAlert, showSuccessAlert } from "@/utils/alerts"; @@ -69,137 +70,93 @@ export const AvailabilityDetailScreen = forwardRef< const router = useRouter(); const insets = useSafeAreaInsets(); - const [loading, setLoading] = useState(true); - const [_schedule, setSchedule] = useState(null); - const [scheduleName, setScheduleName] = useState(""); - const [timeZone, setTimeZone] = useState(""); - const [isDefault, setIsDefault] = useState(false); - const [availability, setAvailability] = useState>({}); - const [overrides, setOverrides] = useState< - { - date: string; - startTime: string; - endTime: string; - }[] - >([]); + // Use React Query hook for data fetching and cache synchronization + const { data: schedule, isLoading, error, refetch, isRefetching } = useScheduleById(Number(id)); - const processScheduleData = useCallback( - (scheduleData: NonNullable>>) => { - const name = scheduleData.name ?? ""; - const tz = scheduleData.timeZone ?? "UTC"; - const isDefaultSchedule = scheduleData.isDefault ?? false; + // Mutation hooks for actions + const { mutate: setAsDefaultMutation } = useSetScheduleAsDefault(); + const { mutate: deleteScheduleMutation } = useDeleteSchedule(); - setSchedule(scheduleData); - setScheduleName(name); - setTimeZone(tz); - setIsDefault(isDefaultSchedule); + // Derive schedule properties from the query data + const scheduleName = schedule?.name ?? ""; + const timeZone = schedule?.timeZone ?? ""; + const isDefault = schedule?.isDefault ?? false; - const availabilityMap: Record = {}; + // Process availability data into a map by day number + const availability = useMemo(() => { + const availabilityMap: Record = {}; - const availabilityArray = scheduleData.availability; - if (availabilityArray && Array.isArray(availabilityArray)) { - availabilityArray.forEach((slot) => { - let days: number[] = []; - if (Array.isArray(slot.days)) { - days = slot.days - .map((day) => { - if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) { - return DAY_NAME_TO_NUMBER[day]; + const availabilityArray = schedule?.availability; + if (availabilityArray && Array.isArray(availabilityArray)) { + availabilityArray.forEach((slot) => { + let days: number[] = []; + if (Array.isArray(slot.days)) { + days = slot.days + .map((day) => { + if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) { + return DAY_NAME_TO_NUMBER[day]; + } + if (typeof day === "string") { + const parsed = parseInt(day, 10); + if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) { + return parsed; } - if (typeof day === "string") { - const parsed = parseInt(day, 10); - if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) { - return parsed; - } - } - if (typeof day === "number" && day >= 0 && day <= 6) { - return day; - } - return null; - }) - .filter((day): day is number => day !== null); + } + if (typeof day === "number" && day >= 0 && day <= 6) { + return day; + } + return null; + }) + .filter((day): day is number => day !== null); + } + + days.forEach((day) => { + if (!availabilityMap[day]) { + availabilityMap[day] = []; } - - days.forEach((day) => { - if (!availabilityMap[day]) { - availabilityMap[day] = []; - } - const startTime = slot.startTime ?? "09:00:00"; - const endTime = slot.endTime ?? "17:00:00"; - availabilityMap[day].push({ - days: [day.toString()], - startTime, - endTime, - }); + const startTime = slot.startTime ?? "09:00:00"; + const endTime = slot.endTime ?? "17:00:00"; + availabilityMap[day].push({ + days: [day.toString()], + startTime, + endTime, }); }); - } - - setAvailability(availabilityMap); - - const overridesArray = scheduleData.overrides; - if (overridesArray && Array.isArray(overridesArray)) { - const formattedOverrides = overridesArray.map((override) => { - const date = override.date ?? ""; - const startTime = override.startTime ?? "00:00"; - const endTime = override.endTime ?? "00:00"; - return { date, startTime, endTime }; - }); - setOverrides(formattedOverrides); - } else { - setOverrides([]); - } - }, - [] - ); - - const fetchSchedule = useCallback(async () => { - setLoading(true); - let scheduleData: Awaited> = null; - try { - scheduleData = await CalComAPIService.getScheduleById(Number(id)); - } catch (error) { - console.error("Error fetching schedule"); - if (__DEV__) { - const message = error instanceof Error ? error.message : String(error); - console.debug("[AvailabilityDetailScreen.ios] fetchSchedule failed", { - message, - }); - } - showErrorAlert("Error", "Failed to load availability. Please try again."); - router.back(); - setLoading(false); - return; + }); } - if (scheduleData) { - processScheduleData(scheduleData); - } - setLoading(false); - }, [id, router, processScheduleData]); + return availabilityMap; + }, [schedule?.availability]); - useEffect(() => { - if (id) { - fetchSchedule(); + // Process overrides data + const overrides = useMemo(() => { + const overridesArray = schedule?.overrides; + if (overridesArray && Array.isArray(overridesArray)) { + return overridesArray.map((override) => { + const date = override.date ?? ""; + const startTime = override.startTime ?? "00:00"; + const endTime = override.endTime ?? "00:00"; + return { date, startTime, endTime }; + }); } - }, [id, fetchSchedule]); + return []; + }, [schedule?.overrides]); - const handleSetAsDefault = useCallback(async () => { + const handleSetAsDefault = useCallback(() => { if (isDefault) { showInfoAlert("Info", "This schedule is already set as default"); return; } - try { - await CalComAPIService.updateSchedule(Number(id), { - isDefault: true, - }); - setIsDefault(true); - showSuccessAlert("Success", "Availability set as default successfully"); - } catch { - showErrorAlert("Error", "Failed to set availability as default. Please try again."); - } - }, [isDefault, id]); + setAsDefaultMutation(Number(id), { + onSuccess: () => { + showSuccessAlert("Success", "Availability set as default successfully"); + }, + onError: () => { + showErrorAlert("Error", "Failed to set availability as default. Please try again."); + }, + }); + }, [isDefault, id, setAsDefaultMutation]); const handleDelete = useCallback(() => { if (isDefault) { @@ -215,19 +172,21 @@ export const AvailabilityDetailScreen = forwardRef< { text: "Delete", style: "destructive", - onPress: async () => { - try { - await CalComAPIService.deleteSchedule(Number(id)); - Alert.alert("Success", "Availability deleted successfully", [ - { text: "OK", onPress: () => router.back() }, - ]); - } catch { - showErrorAlert("Error", "Failed to delete availability. Please try again."); - } + onPress: () => { + deleteScheduleMutation(Number(id), { + onSuccess: () => { + Alert.alert("Success", "Availability deleted successfully", [ + { text: "OK", onPress: () => router.back() }, + ]); + }, + onError: () => { + showErrorAlert("Error", "Failed to delete availability. Please try again."); + }, + }); }, }, ]); - }, [isDefault, scheduleName, id, router]); + }, [isDefault, scheduleName, id, router, deleteScheduleMutation]); // Expose handlers via ref useImperativeHandle( @@ -235,9 +194,9 @@ export const AvailabilityDetailScreen = forwardRef< () => ({ setAsDefault: handleSetAsDefault, delete: handleDelete, - refresh: fetchSchedule, + refresh: () => refetch(), }), - [handleSetAsDefault, handleDelete, fetchSchedule] + [handleSetAsDefault, handleDelete, refetch] ); // Expose handlers to parent for iOS header menu @@ -250,10 +209,23 @@ export const AvailabilityDetailScreen = forwardRef< } }, [onActionsReady, handleSetAsDefault, handleDelete]); + // Handle error state - must be in useEffect to avoid side effects during render + useEffect(() => { + if (error) { + showErrorAlert("Error", "Failed to load availability. Please try again."); + router.back(); + } + }, [error, router]); + // Count enabled days const enabledDaysCount = Object.keys(availability).length; - if (loading) { + // Early return for error state (after useEffect hooks) + if (error) { + return null; + } + + if (isLoading) { return ( @@ -272,6 +244,7 @@ export const AvailabilityDetailScreen = forwardRef< paddingBottom: insets.bottom + 100, }} showsVerticalScrollIndicator={false} + refreshControl={ refetch()} />} > {/* Schedule Title Section - iOS Calendar Style */} @@ -409,13 +382,20 @@ export const AvailabilityDetailScreen = forwardRef< )} - {/* No Overrides Message */} + {/* No Overrides Message - Still navigable to add overrides */} {overrides.length === 0 && ( - - Date Overrides - No date overrides set - + router.push(`/edit-availability-override?id=${id}` as never)} + > + + + Date Overrides + No date overrides set + + + + )} diff --git a/companion/components/screens/AvailabilityDetailScreen.tsx b/companion/components/screens/AvailabilityDetailScreen.tsx index a2724aab07..3c33be78d8 100644 --- a/companion/components/screens/AvailabilityDetailScreen.tsx +++ b/companion/components/screens/AvailabilityDetailScreen.tsx @@ -3,15 +3,16 @@ * * Read-only display of availability schedule. * Editing is done via separate modal screens. + * Uses React Query for cache synchronization with edit screens. */ import { Ionicons } from "@expo/vector-icons"; import { useRouter } from "expo-router"; -import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react"; -import { ActivityIndicator, Alert, ScrollView, Text, View } from "react-native"; +import { forwardRef, useCallback, useImperativeHandle, useMemo } from "react"; +import { ActivityIndicator, Alert, RefreshControl, ScrollView, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppPressable } from "@/components/AppPressable"; -import { CalComAPIService, type Schedule } from "@/services/calcom"; +import { useDeleteSchedule, useScheduleById, useSetScheduleAsDefault } from "@/hooks/useSchedules"; import type { ScheduleAvailability } from "@/services/types"; import { showErrorAlert } from "@/utils/alerts"; @@ -69,137 +70,93 @@ export const AvailabilityDetailScreen = forwardRef< const router = useRouter(); const insets = useSafeAreaInsets(); - const [loading, setLoading] = useState(true); - const [_schedule, setSchedule] = useState(null); - const [scheduleName, setScheduleName] = useState(""); - const [timeZone, setTimeZone] = useState(""); - const [isDefault, setIsDefault] = useState(false); - const [availability, setAvailability] = useState>({}); - const [overrides, setOverrides] = useState< - { - date: string; - startTime: string; - endTime: string; - }[] - >([]); + // Use React Query hook for data fetching and cache synchronization + const { data: schedule, isLoading, error, refetch, isRefetching } = useScheduleById(Number(id)); - const processScheduleData = useCallback( - (scheduleData: NonNullable>>) => { - const name = scheduleData.name ?? ""; - const tz = scheduleData.timeZone ?? "UTC"; - const isDefaultSchedule = scheduleData.isDefault ?? false; + // Mutation hooks for actions + const { mutate: setAsDefaultMutation } = useSetScheduleAsDefault(); + const { mutate: deleteScheduleMutation } = useDeleteSchedule(); - setSchedule(scheduleData); - setScheduleName(name); - setTimeZone(tz); - setIsDefault(isDefaultSchedule); + // Derive schedule properties from the query data + const scheduleName = schedule?.name ?? ""; + const timeZone = schedule?.timeZone ?? ""; + const isDefault = schedule?.isDefault ?? false; - const availabilityMap: Record = {}; + // Process availability data into a map by day number + const availability = useMemo(() => { + const availabilityMap: Record = {}; - const availabilityArray = scheduleData.availability; - if (availabilityArray && Array.isArray(availabilityArray)) { - availabilityArray.forEach((slot) => { - let days: number[] = []; - if (Array.isArray(slot.days)) { - days = slot.days - .map((day) => { - if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) { - return DAY_NAME_TO_NUMBER[day]; + const availabilityArray = schedule?.availability; + if (availabilityArray && Array.isArray(availabilityArray)) { + availabilityArray.forEach((slot) => { + let days: number[] = []; + if (Array.isArray(slot.days)) { + days = slot.days + .map((day) => { + if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) { + return DAY_NAME_TO_NUMBER[day]; + } + if (typeof day === "string") { + const parsed = parseInt(day, 10); + if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) { + return parsed; } - if (typeof day === "string") { - const parsed = parseInt(day, 10); - if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) { - return parsed; - } - } - if (typeof day === "number" && day >= 0 && day <= 6) { - return day; - } - return null; - }) - .filter((day): day is number => day !== null); + } + if (typeof day === "number" && day >= 0 && day <= 6) { + return day; + } + return null; + }) + .filter((day): day is number => day !== null); + } + + days.forEach((day) => { + if (!availabilityMap[day]) { + availabilityMap[day] = []; } - - days.forEach((day) => { - if (!availabilityMap[day]) { - availabilityMap[day] = []; - } - const startTime = slot.startTime ?? "09:00:00"; - const endTime = slot.endTime ?? "17:00:00"; - availabilityMap[day].push({ - days: [day.toString()], - startTime, - endTime, - }); + const startTime = slot.startTime ?? "09:00:00"; + const endTime = slot.endTime ?? "17:00:00"; + availabilityMap[day].push({ + days: [day.toString()], + startTime, + endTime, }); }); - } - - setAvailability(availabilityMap); - - const overridesArray = scheduleData.overrides; - if (overridesArray && Array.isArray(overridesArray)) { - const formattedOverrides = overridesArray.map((override) => { - const date = override.date ?? ""; - const startTime = override.startTime ?? "00:00"; - const endTime = override.endTime ?? "00:00"; - return { date, startTime, endTime }; - }); - setOverrides(formattedOverrides); - } else { - setOverrides([]); - } - }, - [] - ); - - const fetchSchedule = useCallback(async () => { - setLoading(true); - let scheduleData: Awaited> = null; - try { - scheduleData = await CalComAPIService.getScheduleById(Number(id)); - } catch (error) { - console.error("Error fetching schedule"); - if (__DEV__) { - const message = error instanceof Error ? error.message : String(error); - console.debug("[AvailabilityDetailScreen] fetchSchedule failed", { - message, - }); - } - showErrorAlert("Error", "Failed to load availability. Please try again."); - router.back(); - setLoading(false); - return; + }); } - if (scheduleData) { - processScheduleData(scheduleData); - } - setLoading(false); - }, [id, router, processScheduleData]); + return availabilityMap; + }, [schedule?.availability]); - useEffect(() => { - if (id) { - fetchSchedule(); + // Process overrides data + const overrides = useMemo(() => { + const overridesArray = schedule?.overrides; + if (overridesArray && Array.isArray(overridesArray)) { + return overridesArray.map((override) => { + const date = override.date ?? ""; + const startTime = override.startTime ?? "00:00"; + const endTime = override.endTime ?? "00:00"; + return { date, startTime, endTime }; + }); } - }, [id, fetchSchedule]); + return []; + }, [schedule?.overrides]); - const handleSetAsDefault = useCallback(async () => { + const handleSetAsDefault = useCallback(() => { if (isDefault) { Alert.alert("Info", "This schedule is already set as default"); return; } - try { - await CalComAPIService.updateSchedule(Number(id), { - isDefault: true, - }); - setIsDefault(true); - Alert.alert("Success", "Availability set as default successfully"); - } catch { - showErrorAlert("Error", "Failed to set availability as default. Please try again."); - } - }, [isDefault, id]); + setAsDefaultMutation(Number(id), { + onSuccess: () => { + Alert.alert("Success", "Availability set as default successfully"); + }, + onError: () => { + showErrorAlert("Error", "Failed to set availability as default. Please try again."); + }, + }); + }, [isDefault, id, setAsDefaultMutation]); const handleDelete = useCallback(() => { if (isDefault) { @@ -215,19 +172,21 @@ export const AvailabilityDetailScreen = forwardRef< { text: "Delete", style: "destructive", - onPress: async () => { - try { - await CalComAPIService.deleteSchedule(Number(id)); - Alert.alert("Success", "Availability deleted successfully", [ - { text: "OK", onPress: () => router.back() }, - ]); - } catch { - showErrorAlert("Error", "Failed to delete availability. Please try again."); - } + onPress: () => { + deleteScheduleMutation(Number(id), { + onSuccess: () => { + Alert.alert("Success", "Availability deleted successfully", [ + { text: "OK", onPress: () => router.back() }, + ]); + }, + onError: () => { + showErrorAlert("Error", "Failed to delete availability. Please try again."); + }, + }); }, }, ]); - }, [isDefault, scheduleName, id, router]); + }, [isDefault, scheduleName, id, router, deleteScheduleMutation]); // Expose handlers via ref useImperativeHandle( @@ -235,9 +194,9 @@ export const AvailabilityDetailScreen = forwardRef< () => ({ setAsDefault: handleSetAsDefault, delete: handleDelete, - refresh: fetchSchedule, + refresh: () => refetch(), }), - [handleSetAsDefault, handleDelete, fetchSchedule] + [handleSetAsDefault, handleDelete, refetch] ); // Expose handlers to parent for header menu @@ -250,10 +209,23 @@ export const AvailabilityDetailScreen = forwardRef< } }, [onActionsReady, handleSetAsDefault, handleDelete]); + // Handle error state - must be in useEffect to avoid side effects during render + useEffect(() => { + if (error) { + showErrorAlert("Error", "Failed to load availability. Please try again."); + router.back(); + } + }, [error, router]); + // Count enabled days const enabledDaysCount = Object.keys(availability).length; - if (loading) { + // Early return for error state (after useEffect hooks) + if (error) { + return null; + } + + if (isLoading) { return ( @@ -272,6 +244,7 @@ export const AvailabilityDetailScreen = forwardRef< paddingBottom: insets.bottom + 100, }} showsVerticalScrollIndicator={false} + refreshControl={ refetch()} />} > {/* Schedule Title Section */} @@ -409,13 +382,20 @@ export const AvailabilityDetailScreen = forwardRef< )} - {/* No Overrides Message */} + {/* No Overrides Message - Still navigable to add overrides */} {overrides.length === 0 && ( - - Date Overrides - No date overrides set - + router.push(`/edit-availability-override?id=${id}` as never)} + > + + + Date Overrides + No date overrides set + + + + )} diff --git a/companion/components/screens/EditAvailabilityDayScreen.ios.tsx b/companion/components/screens/EditAvailabilityDayScreen.ios.tsx index 2b2308ddff..d6d3591913 100644 --- a/companion/components/screens/EditAvailabilityDayScreen.ios.tsx +++ b/companion/components/screens/EditAvailabilityDayScreen.ios.tsx @@ -4,8 +4,8 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } fro import { Alert, ScrollView, Switch, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppPressable } from "@/components/AppPressable"; +import { useUpdateSchedule } from "@/hooks/useSchedules"; import type { Schedule } from "@/services/calcom"; -import { CalComAPIService } from "@/services/calcom"; import type { ScheduleAvailability } from "@/services/types"; import { showErrorAlert } from "@/utils/alerts"; @@ -169,9 +169,11 @@ export const EditAvailabilityDayScreen = forwardRef< const insets = useSafeAreaInsets(); const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]"; + // Use mutation hook for cache-synchronized updates + const { mutate: updateSchedule, isPending: isMutating } = useUpdateSchedule(); + const [isEnabled, setIsEnabled] = useState(false); const [slots, setSlots] = useState<{ startTime: Date; endTime: Date }[]>([]); - const [isSaving, setIsSaving] = useState(false); const dayName = DAYS[dayIndex] || "Day"; @@ -201,8 +203,8 @@ export const EditAvailabilityDayScreen = forwardRef< // Notify parent of saving state useEffect(() => { - onSavingChange?.(isSaving); - }, [isSaving, onSavingChange]); + onSavingChange?.(isMutating); + }, [isMutating, onSavingChange]); const handleToggle = useCallback( (value: boolean) => { @@ -249,8 +251,8 @@ export const EditAvailabilityDayScreen = forwardRef< }); }, []); - const handleSubmit = useCallback(async () => { - if (!schedule || isSaving) return; + const handleSubmit = useCallback(() => { + if (!schedule || isMutating) return; // Validate all slots have end time after start time // Compare time strings to avoid issues with Date object day components @@ -276,20 +278,20 @@ export const EditAvailabilityDayScreen = forwardRef< const fullAvailability = buildFullAvailability(schedule, dayIndex, daySlots); - setIsSaving(true); - try { - await CalComAPIService.updateSchedule(schedule.id, { - availability: fullAvailability, - }); - Alert.alert("Success", `${dayName} updated successfully`, [ - { text: "OK", onPress: onSuccess }, - ]); - setIsSaving(false); - } catch { - showErrorAlert("Error", "Failed to update schedule. Please try again."); - setIsSaving(false); - } - }, [schedule, dayIndex, dayName, isEnabled, slots, onSuccess, isSaving]); + updateSchedule( + { id: schedule.id, updates: { availability: fullAvailability } }, + { + onSuccess: () => { + Alert.alert("Success", `${dayName} updated successfully`, [ + { text: "OK", onPress: onSuccess }, + ]); + }, + onError: () => { + showErrorAlert("Error", "Failed to update schedule. Please try again."); + }, + } + ); + }, [schedule, dayIndex, dayName, isEnabled, slots, onSuccess, isMutating, updateSchedule]); // Expose submit to parent via ref useImperativeHandle( diff --git a/companion/components/screens/EditAvailabilityDayScreen.tsx b/companion/components/screens/EditAvailabilityDayScreen.tsx index 22db9f1d8e..167114160d 100644 --- a/companion/components/screens/EditAvailabilityDayScreen.tsx +++ b/companion/components/screens/EditAvailabilityDayScreen.tsx @@ -4,8 +4,8 @@ import { Alert, Platform, ScrollView, Switch, Text, TouchableOpacity, View } fro import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppPressable } from "@/components/AppPressable"; import { FullScreenModal } from "@/components/FullScreenModal"; +import { useUpdateSchedule } from "@/hooks/useSchedules"; import type { Schedule } from "@/services/calcom"; -import { CalComAPIService } from "@/services/calcom"; import type { ScheduleAvailability } from "@/services/types"; import { showErrorAlert, showSuccessAlert } from "@/utils/alerts"; import { shadows } from "@/utils/shadows"; @@ -170,9 +170,11 @@ export const EditAvailabilityDayScreen = forwardRef< >(function EditAvailabilityDayScreen({ schedule, dayIndex, onSuccess, onSavingChange }, ref) { const insets = useSafeAreaInsets(); + // Use mutation hook for cache-synchronized updates + const { mutate: updateSchedule, isPending: isMutating } = useUpdateSchedule(); + const [isEnabled, setIsEnabled] = useState(false); const [slots, setSlots] = useState<{ startTime: string; endTime: string }[]>([]); - const [isSaving, setIsSaving] = useState(false); const [showTimePicker, setShowTimePicker] = useState<{ slotIndex: number; type: "start" | "end"; @@ -252,8 +254,8 @@ export const EditAvailabilityDayScreen = forwardRef< // Notify parent of saving state useEffect(() => { - onSavingChange?.(isSaving); - }, [isSaving, onSavingChange]); + onSavingChange?.(isMutating); + }, [isMutating, onSavingChange]); const handleToggle = useCallback( (value: boolean) => { @@ -297,8 +299,8 @@ export const EditAvailabilityDayScreen = forwardRef< [showTimePicker] ); - const handleSubmit = useCallback(async () => { - if (!schedule || isSaving) return; + const handleSubmit = useCallback(() => { + if (!schedule || isMutating) return; // Validate all slots have end time after start time if (isEnabled) { @@ -320,19 +322,19 @@ export const EditAvailabilityDayScreen = forwardRef< const fullAvailability = buildFullAvailability(schedule, dayIndex, daySlots); - setIsSaving(true); - try { - await CalComAPIService.updateSchedule(schedule.id, { - availability: fullAvailability, - }); - showSuccessAlert("Success", `${dayName} updated successfully`); - onSuccess(); - setIsSaving(false); - } catch { - showErrorAlert("Error", "Failed to update schedule. Please try again."); - setIsSaving(false); - } - }, [schedule, dayIndex, dayName, isEnabled, slots, onSuccess, isSaving]); + updateSchedule( + { id: schedule.id, updates: { availability: fullAvailability } }, + { + onSuccess: () => { + showSuccessAlert("Success", `${dayName} updated successfully`); + onSuccess(); + }, + onError: () => { + showErrorAlert("Error", "Failed to update schedule. Please try again."); + }, + } + ); + }, [schedule, dayIndex, dayName, isEnabled, slots, onSuccess, isMutating, updateSchedule]); useImperativeHandle( ref, diff --git a/companion/components/screens/EditAvailabilityNameScreen.ios.tsx b/companion/components/screens/EditAvailabilityNameScreen.ios.tsx index d2c5992c0a..12d697a6e2 100644 --- a/companion/components/screens/EditAvailabilityNameScreen.ios.tsx +++ b/companion/components/screens/EditAvailabilityNameScreen.ios.tsx @@ -6,8 +6,7 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } fro import { Alert, KeyboardAvoidingView, ScrollView, Text, TextInput, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { TIMEZONES as ALL_TIMEZONES } from "@/constants/timezones"; -import type { Schedule } from "@/services/calcom"; -import { CalComAPIService } from "@/services/calcom"; +import { type Schedule, useUpdateSchedule } from "@/hooks/useSchedules"; import { showErrorAlert } from "@/utils/alerts"; // Format timezones for display @@ -39,7 +38,9 @@ export const EditAvailabilityNameScreen = forwardRef< const [name, setName] = useState(""); const [timezone, setTimezone] = useState("UTC"); - const [isSaving, setIsSaving] = useState(false); + + // Use the mutation hook for updating schedules with optimistic updates + const { mutate: updateSchedule, isPending: isSaving } = useUpdateSchedule(); // Initialize from schedule useEffect(() => { @@ -58,7 +59,7 @@ export const EditAvailabilityNameScreen = forwardRef< setTimezone(tz); }, []); - const handleSubmit = useCallback(async () => { + const handleSubmit = useCallback(() => { if (!schedule || isSaving) return; const trimmedName = name.trim(); @@ -67,19 +68,26 @@ export const EditAvailabilityNameScreen = forwardRef< return; } - setIsSaving(true); - try { - await CalComAPIService.updateSchedule(schedule.id, { - name: trimmedName, - timeZone: timezone, - }); - Alert.alert("Success", "Schedule updated successfully", [{ text: "OK", onPress: onSuccess }]); - setIsSaving(false); - } catch { - showErrorAlert("Error", "Failed to update schedule. Please try again."); - setIsSaving(false); - } - }, [schedule, name, timezone, onSuccess, isSaving]); + updateSchedule( + { + id: schedule.id, + updates: { + name: trimmedName, + timeZone: timezone, + }, + }, + { + onSuccess: () => { + Alert.alert("Success", "Schedule updated successfully", [ + { text: "OK", onPress: onSuccess }, + ]); + }, + onError: () => { + showErrorAlert("Error", "Failed to update schedule. Please try again."); + }, + } + ); + }, [schedule, name, timezone, onSuccess, isSaving, updateSchedule]); // Expose submit to parent via ref useImperativeHandle( diff --git a/companion/components/screens/EditAvailabilityNameScreen.tsx b/companion/components/screens/EditAvailabilityNameScreen.tsx index dec4627c85..ea62c9d58b 100644 --- a/companion/components/screens/EditAvailabilityNameScreen.tsx +++ b/companion/components/screens/EditAvailabilityNameScreen.tsx @@ -14,8 +14,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppPressable } from "@/components/AppPressable"; import { FullScreenModal } from "@/components/FullScreenModal"; import { TIMEZONES as ALL_TIMEZONES } from "@/constants/timezones"; -import type { Schedule } from "@/services/calcom"; -import { CalComAPIService } from "@/services/calcom"; +import { type Schedule, useUpdateSchedule } from "@/hooks/useSchedules"; import { showErrorAlert, showSuccessAlert } from "@/utils/alerts"; import { shadows } from "@/utils/shadows"; @@ -44,9 +43,11 @@ export const EditAvailabilityNameScreen = forwardRef< const [name, setName] = useState(""); const [timezone, setTimezone] = useState("UTC"); - const [isSaving, setIsSaving] = useState(false); const [showTimezoneModal, setShowTimezoneModal] = useState(false); + // Use the mutation hook for updating schedules with optimistic updates + const { mutate: updateSchedule, isPending: isSaving } = useUpdateSchedule(); + // Initialize from schedule useEffect(() => { if (schedule) { @@ -60,7 +61,7 @@ export const EditAvailabilityNameScreen = forwardRef< onSavingChange?.(isSaving); }, [isSaving, onSavingChange]); - const handleSubmit = useCallback(async () => { + const handleSubmit = useCallback(() => { if (!schedule || isSaving) return; const trimmedName = name.trim(); @@ -69,20 +70,25 @@ export const EditAvailabilityNameScreen = forwardRef< return; } - setIsSaving(true); - try { - await CalComAPIService.updateSchedule(schedule.id, { - name: trimmedName, - timeZone: timezone, - }); - showSuccessAlert("Success", "Schedule updated successfully"); - onSuccess(); - setIsSaving(false); - } catch { - showErrorAlert("Error", "Failed to update schedule. Please try again."); - setIsSaving(false); - } - }, [schedule, name, timezone, onSuccess, isSaving]); + updateSchedule( + { + id: schedule.id, + updates: { + name: trimmedName, + timeZone: timezone, + }, + }, + { + onSuccess: () => { + showSuccessAlert("Success", "Schedule updated successfully"); + onSuccess(); + }, + onError: () => { + showErrorAlert("Error", "Failed to update schedule. Please try again."); + }, + } + ); + }, [schedule, name, timezone, onSuccess, isSaving, updateSchedule]); // Expose submit to parent via ref useImperativeHandle( diff --git a/companion/components/screens/EditAvailabilityOverrideScreen.ios.tsx b/companion/components/screens/EditAvailabilityOverrideScreen.ios.tsx index eb12244e8b..3c3adc2663 100644 --- a/companion/components/screens/EditAvailabilityOverrideScreen.ios.tsx +++ b/companion/components/screens/EditAvailabilityOverrideScreen.ios.tsx @@ -3,8 +3,8 @@ import { Ionicons } from "@expo/vector-icons"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react"; import { Alert, Pressable, ScrollView, Switch, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useUpdateSchedule } from "@/hooks/useSchedules"; import type { Schedule } from "@/services/calcom"; -import { CalComAPIService } from "@/services/calcom"; import { showErrorAlert, showSuccessAlert } from "@/utils/alerts"; // Convert 24-hour time to 12-hour format with AM/PM @@ -89,13 +89,15 @@ export const EditAvailabilityOverrideScreen = forwardRef< const insets = useSafeAreaInsets(); const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]"; + // Use mutation hook for cache-synchronized updates + const { mutate: updateSchedule, isPending: isMutating } = useUpdateSchedule(); + const isEditing = overrideIndex !== undefined; const [selectedDate, setSelectedDate] = useState(new Date()); const [isUnavailable, setIsUnavailable] = useState(false); const [startTime, setStartTime] = useState(timeStringToDate("09:00")); const [endTime, setEndTime] = useState(timeStringToDate("17:00")); - const [isSaving, setIsSaving] = useState(false); // Initialize from existing override if editing useEffect(() => { @@ -114,8 +116,8 @@ export const EditAvailabilityOverrideScreen = forwardRef< // Notify parent of saving state useEffect(() => { - onSavingChange?.(isSaving); - }, [isSaving, onSavingChange]); + onSavingChange?.(isMutating); + }, [isMutating, onSavingChange]); const handleDateChange = useCallback((date: Date) => { setSelectedDate(date); @@ -130,26 +132,26 @@ export const EditAvailabilityOverrideScreen = forwardRef< }, []); const saveOverrides = useCallback( - async ( + ( newOverrides: { date: string; startTime: string; endTime: string }[], successMessage: string ) => { if (!schedule) return; - setIsSaving(true); - try { - await CalComAPIService.updateSchedule(schedule.id, { - overrides: newOverrides, - }); - showSuccessAlert("Success", successMessage); - onSuccess(); - setIsSaving(false); - } catch { - showErrorAlert("Error", "Failed to save override. Please try again."); - setIsSaving(false); - } + updateSchedule( + { id: schedule.id, updates: { overrides: newOverrides } }, + { + onSuccess: () => { + showSuccessAlert("Success", successMessage); + onSuccess(); + }, + onError: () => { + showErrorAlert("Error", "Failed to save override. Please try again."); + }, + } + ); }, - [schedule, onSuccess] + [schedule, onSuccess, updateSchedule] ); const handleDeleteOverride = useCallback( @@ -186,8 +188,8 @@ export const EditAvailabilityOverrideScreen = forwardRef< [schedule, saveOverrides] ); - const handleSubmit = useCallback(async () => { - if (!schedule || isSaving) return; + const handleSubmit = useCallback(() => { + if (!schedule || isMutating) return; const dateStr = dateToDateString(selectedDate); @@ -236,9 +238,9 @@ export const EditAvailabilityOverrideScreen = forwardRef< { text: "Cancel", style: "cancel" }, { text: "Replace", - onPress: async () => { + onPress: () => { newOverrides[existingIndex] = newOverride; - await saveOverrides(newOverrides, "Override replaced successfully"); + saveOverrides(newOverrides, "Override replaced successfully"); }, }, ] @@ -249,7 +251,7 @@ export const EditAvailabilityOverrideScreen = forwardRef< newOverrides.push(newOverride); } - await saveOverrides(newOverrides, successMessage); + saveOverrides(newOverrides, successMessage); }, [ schedule, selectedDate, @@ -259,7 +261,7 @@ export const EditAvailabilityOverrideScreen = forwardRef< isEditing, overrideIndex, saveOverrides, - isSaving, + isMutating, ]); // Expose submit to parent via ref diff --git a/companion/components/screens/EditAvailabilityOverrideScreen.tsx b/companion/components/screens/EditAvailabilityOverrideScreen.tsx index e02908527d..bb76d3bc4d 100644 --- a/companion/components/screens/EditAvailabilityOverrideScreen.tsx +++ b/companion/components/screens/EditAvailabilityOverrideScreen.tsx @@ -13,8 +13,8 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppPressable } from "@/components/AppPressable"; import { FullScreenModal } from "@/components/FullScreenModal"; +import { useUpdateSchedule } from "@/hooks/useSchedules"; import type { Schedule } from "@/services/calcom"; -import { CalComAPIService } from "@/services/calcom"; import { showErrorAlert, showSuccessAlert } from "@/utils/alerts"; import { shadows } from "@/utils/shadows"; @@ -77,13 +77,15 @@ export const EditAvailabilityOverrideScreen = forwardRef< ) { const insets = useSafeAreaInsets(); + // Use mutation hook for cache-synchronized updates + const { mutate: updateSchedule, isPending: isMutating } = useUpdateSchedule(); + const isEditing = overrideIndex !== undefined; const [selectedDate, setSelectedDate] = useState(""); const [isUnavailable, setIsUnavailable] = useState(false); const [startTime, setStartTime] = useState("09:00"); const [endTime, setEndTime] = useState("17:00"); - const [isSaving, setIsSaving] = useState(false); const [showTimePicker, setShowTimePicker] = useState<{ type: "start" | "end"; } | null>(null); @@ -152,8 +154,8 @@ export const EditAvailabilityOverrideScreen = forwardRef< // Notify parent of saving state useEffect(() => { - onSavingChange?.(isSaving); - }, [isSaving, onSavingChange]); + onSavingChange?.(isMutating); + }, [isMutating, onSavingChange]); const handleTimeSelect = useCallback( (time: string) => { @@ -170,30 +172,30 @@ export const EditAvailabilityOverrideScreen = forwardRef< ); const saveOverrides = useCallback( - async ( + ( newOverrides: { date: string; startTime: string; endTime: string }[], successMessage: string ) => { if (!schedule) return; - setIsSaving(true); - try { - await CalComAPIService.updateSchedule(schedule.id, { - overrides: newOverrides, - }); - setIsSaving(false); - if (Platform.OS === "web") { - showSuccessAlert("Success", successMessage); - onSuccess(); - } else { - Alert.alert("Success", successMessage, [{ text: "OK", onPress: onSuccess }]); + updateSchedule( + { id: schedule.id, updates: { overrides: newOverrides } }, + { + onSuccess: () => { + if (Platform.OS === "web") { + showSuccessAlert("Success", successMessage); + onSuccess(); + } else { + Alert.alert("Success", successMessage, [{ text: "OK", onPress: onSuccess }]); + } + }, + onError: () => { + showErrorAlert("Error", "Failed to save override. Please try again."); + }, } - } catch { - showErrorAlert("Error", "Failed to save override. Please try again."); - setIsSaving(false); - } + ); }, - [schedule, onSuccess] + [schedule, onSuccess, updateSchedule] ); const handleDeleteOverride = useCallback( @@ -230,8 +232,8 @@ export const EditAvailabilityOverrideScreen = forwardRef< [schedule, saveOverrides] ); - const handleSubmit = useCallback(async () => { - if (!schedule || isSaving) return; + const handleSubmit = useCallback(() => { + if (!schedule || isMutating) return; if (!selectedDate) { showErrorAlert("Error", "Please enter a date (YYYY-MM-DD format)"); @@ -283,9 +285,9 @@ export const EditAvailabilityOverrideScreen = forwardRef< { text: "Cancel", style: "cancel" }, { text: "Replace", - onPress: async () => { + onPress: () => { newOverrides[existingIndex] = newOverride; - await saveOverrides(newOverrides, "Override replaced successfully"); + saveOverrides(newOverrides, "Override replaced successfully"); }, }, ] @@ -295,7 +297,7 @@ export const EditAvailabilityOverrideScreen = forwardRef< newOverrides.push(newOverride); } - await saveOverrides(newOverrides, successMessage); + saveOverrides(newOverrides, successMessage); }, [ schedule, selectedDate, @@ -305,7 +307,7 @@ export const EditAvailabilityOverrideScreen = forwardRef< isEditing, overrideIndex, saveOverrides, - isSaving, + isMutating, ]); useImperativeHandle( diff --git a/companion/hooks/useSchedules.ts b/companion/hooks/useSchedules.ts index a8a084dc59..eb82687047 100644 --- a/companion/hooks/useSchedules.ts +++ b/companion/hooks/useSchedules.ts @@ -156,7 +156,7 @@ export function useCreateSchedule() { } /** - * Hook to update a schedule + * Hook to update a schedule with optimistic updates * * @returns Mutation function and state * @@ -176,14 +176,74 @@ export function useUpdateSchedule() { return useMutation({ mutationFn: ({ id, updates }: { id: number; updates: UpdateScheduleInput }) => CalComAPIService.updateSchedule(id, updates), - onSuccess: (updatedSchedule, variables) => { - // Invalidate the list - queryClient.invalidateQueries({ queryKey: queryKeys.schedules.lists() }); + onMutate: async ({ id, updates }) => { + // Cancel any outgoing refetches to prevent overwriting optimistic update + await queryClient.cancelQueries({ queryKey: queryKeys.schedules.detail(id) }); + await queryClient.cancelQueries({ queryKey: queryKeys.schedules.lists() }); - // Update the specific schedule in cache - queryClient.setQueryData(queryKeys.schedules.detail(variables.id), updatedSchedule); + // Snapshot the previous values for rollback + const previousSchedule = queryClient.getQueryData( + queryKeys.schedules.detail(id) + ); + const previousSchedules = queryClient.getQueryData(queryKeys.schedules.lists()); + + // Optimistically update the detail cache if it exists + if (previousSchedule) { + const optimisticSchedule: Schedule = { + ...previousSchedule, + ...updates, + name: updates.name ?? previousSchedule.name, + timeZone: updates.timeZone ?? previousSchedule.timeZone, + }; + queryClient.setQueryData(queryKeys.schedules.detail(id), optimisticSchedule); + } + + // Update the list cache optimistically (even if detail cache doesn't exist) + if (previousSchedules) { + const updatedList = previousSchedules.map((s) => { + if (s.id === id) { + // Merge updates into the existing schedule from the list + return { + ...s, + ...updates, + name: updates.name ?? s.name, + timeZone: updates.timeZone ?? s.timeZone, + }; + } + return s; + }); + queryClient.setQueryData(queryKeys.schedules.lists(), sortSchedules(updatedList)); + } + + return { previousSchedule, previousSchedules }; }, - onError: (_error) => { + onSuccess: (updatedSchedule, variables) => { + // Update the specific schedule in cache with server response + queryClient.setQueryData(queryKeys.schedules.detail(variables.id), updatedSchedule); + + // Update the list cache with the server response + const currentSchedules = queryClient.getQueryData(queryKeys.schedules.lists()); + if (currentSchedules) { + const updatedList = currentSchedules.map((s) => + s.id === variables.id ? updatedSchedule : s + ); + queryClient.setQueryData(queryKeys.schedules.lists(), sortSchedules(updatedList)); + } else { + // If list cache doesn't exist, invalidate to trigger refetch when user navigates to list + queryClient.invalidateQueries({ queryKey: queryKeys.schedules.lists() }); + } + }, + onError: (_error, variables, context) => { + // Rollback to previous values on error + if (context?.previousSchedule) { + queryClient.setQueryData( + queryKeys.schedules.detail(variables.id), + context.previousSchedule + ); + } + if (context?.previousSchedules) { + queryClient.setQueryData(queryKeys.schedules.lists(), context.previousSchedules); + } console.error("Failed to update schedule"); }, });