diff --git a/companion/app/edit-availability-hours.ios.tsx b/companion/app/edit-availability-hours.ios.tsx index 34013592526050..2865c7b084879c 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 6b586c8fa24a27..ff194dcd791630 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 ff2feb49a9420f..350e70900eee47 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; - }[] - >([]); - - const processScheduleData = useCallback( - (scheduleData: NonNullable>>) => { - const name = scheduleData.name ?? ""; - const tz = scheduleData.timeZone ?? "UTC"; - const isDefaultSchedule = scheduleData.isDefault ?? false; - - setSchedule(scheduleData); - setScheduleName(name); - setTimeZone(tz); - setIsDefault(isDefaultSchedule); - - 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]; + // Use React Query hook for data fetching and cache synchronization + const { data: schedule, isLoading, error, refetch, isRefetching } = useScheduleById(Number(id)); + + // Mutation hooks for actions + const { mutate: setAsDefaultMutation } = useSetScheduleAsDefault(); + const { mutate: deleteScheduleMutation } = useDeleteSchedule(); + + // Derive schedule properties from the query data + const scheduleName = schedule?.name ?? ""; + const timeZone = schedule?.timeZone ?? ""; + const isDefault = schedule?.isDefault ?? false; + + // Process availability data into a map by day number + const availability = useMemo(() => { + const availabilityMap: Record = {}; + + 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]); - useEffect(() => { - if (id) { - fetchSchedule(); + return availabilityMap; + }, [schedule?.availability]); + + // 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 a2724aab077962..3c33be78d8523d 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; - }[] - >([]); - - const processScheduleData = useCallback( - (scheduleData: NonNullable>>) => { - const name = scheduleData.name ?? ""; - const tz = scheduleData.timeZone ?? "UTC"; - const isDefaultSchedule = scheduleData.isDefault ?? false; - - setSchedule(scheduleData); - setScheduleName(name); - setTimeZone(tz); - setIsDefault(isDefaultSchedule); - - 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]; + // Use React Query hook for data fetching and cache synchronization + const { data: schedule, isLoading, error, refetch, isRefetching } = useScheduleById(Number(id)); + + // Mutation hooks for actions + const { mutate: setAsDefaultMutation } = useSetScheduleAsDefault(); + const { mutate: deleteScheduleMutation } = useDeleteSchedule(); + + // Derive schedule properties from the query data + const scheduleName = schedule?.name ?? ""; + const timeZone = schedule?.timeZone ?? ""; + const isDefault = schedule?.isDefault ?? false; + + // Process availability data into a map by day number + const availability = useMemo(() => { + const availabilityMap: Record = {}; + + 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]); - useEffect(() => { - if (id) { - fetchSchedule(); + return availabilityMap; + }, [schedule?.availability]); + + // 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 2b2308ddff8593..d6d3591913ce60 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 22db9f1d8e770a..167114160d5c2f 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 d2c5992c0a7041..12d697a6e2c30b 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 dec4627c856607..ea62c9d58b5f25 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 eb12244e8b44bd..3c3adc2663c805 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 e02908527d3317..bb76d3bc4da261 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 a8a084dc595871..eb8268704769bb 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 + // 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 }; + }, + 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) => { + 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"); }, });