feat(companion): add optimistic updates to availability edit screens (#26931)
* 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)
This commit is contained in:
@@ -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<Schedule | null>(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) => {
|
||||
|
||||
@@ -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<Schedule | null>(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();
|
||||
|
||||
@@ -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<Schedule | null>(null);
|
||||
const [scheduleName, setScheduleName] = useState("");
|
||||
const [timeZone, setTimeZone] = useState("");
|
||||
const [isDefault, setIsDefault] = useState(false);
|
||||
const [availability, setAvailability] = useState<Record<number, ScheduleAvailability[]>>({});
|
||||
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<Awaited<ReturnType<typeof CalComAPIService.getScheduleById>>>) => {
|
||||
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<number, ScheduleAvailability[]> = {};
|
||||
// Process availability data into a map by day number
|
||||
const availability = useMemo(() => {
|
||||
const availabilityMap: Record<number, ScheduleAvailability[]> = {};
|
||||
|
||||
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<ReturnType<typeof CalComAPIService.getScheduleById>> = 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 (
|
||||
<View className="flex-1 items-center justify-center bg-[#f2f2f7]">
|
||||
<ActivityIndicator size="large" color="#000000" />
|
||||
@@ -272,6 +244,7 @@ export const AvailabilityDetailScreen = forwardRef<
|
||||
paddingBottom: insets.bottom + 100,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={() => refetch()} />}
|
||||
>
|
||||
{/* Schedule Title Section - iOS Calendar Style */}
|
||||
<View className="mb-4">
|
||||
@@ -409,13 +382,20 @@ export const AvailabilityDetailScreen = forwardRef<
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* No Overrides Message */}
|
||||
{/* No Overrides Message - Still navigable to add overrides */}
|
||||
{overrides.length === 0 && (
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
<View className="px-4 py-3.5">
|
||||
<Text className="text-[17px] text-black">Date Overrides</Text>
|
||||
<Text className="mt-1 text-[15px] text-[#8E8E93]">No date overrides set</Text>
|
||||
</View>
|
||||
<AppPressable
|
||||
onPress={() => router.push(`/edit-availability-override?id=${id}` as never)}
|
||||
>
|
||||
<View className="flex-row items-center justify-between px-4 py-3.5">
|
||||
<View>
|
||||
<Text className="text-[17px] text-black">Date Overrides</Text>
|
||||
<Text className="mt-1 text-[15px] text-[#8E8E93]">No date overrides set</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
|
||||
</View>
|
||||
</AppPressable>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
@@ -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<Schedule | null>(null);
|
||||
const [scheduleName, setScheduleName] = useState("");
|
||||
const [timeZone, setTimeZone] = useState("");
|
||||
const [isDefault, setIsDefault] = useState(false);
|
||||
const [availability, setAvailability] = useState<Record<number, ScheduleAvailability[]>>({});
|
||||
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<Awaited<ReturnType<typeof CalComAPIService.getScheduleById>>>) => {
|
||||
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<number, ScheduleAvailability[]> = {};
|
||||
// Process availability data into a map by day number
|
||||
const availability = useMemo(() => {
|
||||
const availabilityMap: Record<number, ScheduleAvailability[]> = {};
|
||||
|
||||
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<ReturnType<typeof CalComAPIService.getScheduleById>> = 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 (
|
||||
<View className="flex-1 items-center justify-center bg-[#f2f2f7]">
|
||||
<ActivityIndicator size="large" color="#000000" />
|
||||
@@ -272,6 +244,7 @@ export const AvailabilityDetailScreen = forwardRef<
|
||||
paddingBottom: insets.bottom + 100,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={() => refetch()} />}
|
||||
>
|
||||
{/* Schedule Title Section */}
|
||||
<View className="mb-4">
|
||||
@@ -409,13 +382,20 @@ export const AvailabilityDetailScreen = forwardRef<
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* No Overrides Message */}
|
||||
{/* No Overrides Message - Still navigable to add overrides */}
|
||||
{overrides.length === 0 && (
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
<View className="px-4 py-3.5">
|
||||
<Text className="text-[17px] text-black">Date Overrides</Text>
|
||||
<Text className="mt-1 text-[15px] text-[#8E8E93]">No date overrides set</Text>
|
||||
</View>
|
||||
<AppPressable
|
||||
onPress={() => router.push(`/edit-availability-override?id=${id}` as never)}
|
||||
>
|
||||
<View className="flex-row items-center justify-between px-4 py-3.5">
|
||||
<View>
|
||||
<Text className="text-[17px] text-black">Date Overrides</Text>
|
||||
<Text className="mt-1 text-[15px] text-[#8E8E93]">No date overrides set</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
|
||||
</View>
|
||||
</AppPressable>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Date>(new Date());
|
||||
const [isUnavailable, setIsUnavailable] = useState(false);
|
||||
const [startTime, setStartTime] = useState<Date>(timeStringToDate("09:00"));
|
||||
const [endTime, setEndTime] = useState<Date>(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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Schedule | null>(
|
||||
queryKeys.schedules.detail(id)
|
||||
);
|
||||
const previousSchedules = queryClient.getQueryData<Schedule[]>(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<Schedule[]>(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");
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user