diff --git a/companion/api/server.ts b/companion/api/server.ts index f4b79bd865..294b211077 100644 --- a/companion/api/server.ts +++ b/companion/api/server.ts @@ -55,7 +55,12 @@ const sendFile = (res: ServerResponse, filePath: string) => { const stream = createReadStream(filePath); stream.on("error", (error) => { - console.error("Failed to read file:", error); + console.error("Failed to read file"); + if (process.env.NODE_ENV !== "production") { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + console.debug("[companion/api] Failed to read file", { message, stack }); + } res.statusCode = 500; res.end("Internal Server Error"); }); @@ -76,7 +81,12 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) const filePath = await resolveFilePath(safePath); sendFile(res, filePath); } catch (error) { - console.error("Server render error:", error); + console.error("Server render error"); + if (process.env.NODE_ENV !== "production") { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + console.debug("[companion/api] Server render error", { message, stack }); + } res.statusCode = 500; res.end("Internal Server Error"); } diff --git a/companion/app/(tabs)/(availability)/index.tsx b/companion/app/(tabs)/(availability)/index.tsx index 462015b486..95444e6d91 100644 --- a/companion/app/(tabs)/(availability)/index.tsx +++ b/companion/app/(tabs)/(availability)/index.tsx @@ -1,306 +1,22 @@ -import { Ionicons } from "@expo/vector-icons"; -import { Stack, useRouter } from "expo-router"; -import React, { useState, useMemo, Activity } from "react"; -import { - View, - Text, - FlatList, - RefreshControl, - TouchableOpacity, - ActionSheetIOS, - Alert, - Platform, - TextInput, - ScrollView, -} from "react-native"; - -import { CalComAPIService, Schedule } from "../../../services/calcom"; -import { Header } from "../../../components/Header"; -import { FullScreenModal } from "../../../components/FullScreenModal"; -import { LoadingSpinner } from "../../../components/LoadingSpinner"; -import { EmptyScreen } from "../../../components/EmptyScreen"; -import { showErrorAlert } from "../../../utils/alerts"; -import { offlineAwareRefresh } from "../../../utils/network"; -import { shadows } from "../../../utils/shadows"; -import { - useSchedules, - useCreateSchedule, - useDeleteSchedule, - useDuplicateSchedule, - useSetScheduleAsDefault, -} from "../../../hooks"; +import { AvailabilityListScreen } from "../../../components/screens/AvailabilityListScreen"; import { isLiquidGlassAvailable } from "expo-glass-effect"; -import { AvailabilityListItem } from "../../../components/availability-list-item/AvailabilityListItem"; +import { Stack } from "expo-router"; +import React, { useState } from "react"; +import { Platform } from "react-native"; export default function Availability() { - const router = useRouter(); const [searchQuery, setSearchQuery] = useState(""); const [showCreateModal, setShowCreateModal] = useState(false); - const [newScheduleName, setNewScheduleName] = useState(""); - const [showActionsModal, setShowActionsModal] = useState(false); - const [selectedSchedule, setSelectedSchedule] = useState(null); - const [showDeleteModal, setShowDeleteModal] = useState(false); - - // Use React Query hooks - const { - data: schedules = [], - isLoading: loading, - isFetching, - error: queryError, - refetch, - } = useSchedules(); - - // Show refresh indicator when fetching - const refreshing = isFetching && !loading; - - const { mutate: createScheduleMutation, isPending: creating } = useCreateSchedule(); - const { mutate: deleteScheduleMutation, isPending: deleting } = useDeleteSchedule(); - const { mutate: duplicateScheduleMutation } = useDuplicateSchedule(); - const { mutate: setAsDefaultMutation } = useSetScheduleAsDefault(); - - // Convert query error to string - // Don't show error UI for authentication errors (user will be redirected to login) - // Only show error UI in development mode for other errors - const isAuthError = - queryError?.message?.includes("Authentication") || - queryError?.message?.includes("sign in") || - queryError?.message?.includes("401"); - const error = queryError && !isAuthError && __DEV__ ? "Failed to load availability." : null; - - // Filter schedules based on search query - const filteredSchedules = useMemo(() => { - if (searchQuery.trim() === "") { - return schedules; - } - const searchLower = searchQuery.toLowerCase(); - return schedules.filter((schedule) => schedule.name.toLowerCase().includes(searchLower)); - }, [schedules, searchQuery]); - - // Note: We don't use useFocusEffect here because schedules have Infinity stale time. - // Data only refreshes on mutations (create/update/delete) or manual pull-to-refresh. - - // Handle pull-to-refresh (offline-aware) - const onRefresh = () => offlineAwareRefresh(refetch); - - const handleSearch = (query: string) => { - setSearchQuery(query); - }; - - const handleScheduleLongPress = (schedule: Schedule) => { - if (Platform.OS !== "ios") { - // Fallback for non-iOS platforms (Android Alert supports max 3 buttons) - const options = []; - if (!schedule.isDefault) { - options.push({ text: "⭐ Set as default", onPress: () => handleSetAsDefault(schedule) }); - } - options.push( - { text: "📋 Duplicate", onPress: () => handleDuplicate(schedule) }, - { text: "🗑️ Delete", style: "destructive", onPress: () => handleDelete(schedule) } - ); - // Android Alert automatically adds cancel, so we don't need to include it explicitly - Alert.alert(schedule.name, "", options); - return; - } - - const options = ["Cancel"]; - if (!schedule.isDefault) { - options.push("⭐ Set as default"); - } - options.push("📋 Duplicate", "🗑️ Delete"); - - const destructiveButtonIndex = options.length - 1; // Delete button - const cancelButtonIndex = 0; - - ActionSheetIOS.showActionSheetWithOptions( - { - options, - destructiveButtonIndex, - cancelButtonIndex, - title: schedule.name, - }, - (buttonIndex) => { - if (buttonIndex === cancelButtonIndex) { - return; - } - - if (!schedule.isDefault) { - // Options: ["Cancel", "⭐ Set as default", "📋 Duplicate", "🗑️ Delete"] - if (buttonIndex === 1) { - handleSetAsDefault(schedule); - } else if (buttonIndex === 2) { - handleDuplicate(schedule); - } else if (buttonIndex === 3) { - handleDelete(schedule); - } - } else { - // Options: ["Cancel", "📋 Duplicate", "🗑️ Delete"] - if (buttonIndex === 1) { - handleDuplicate(schedule); - } else if (buttonIndex === 2) { - handleDelete(schedule); - } - } - } - ); - }; - - const handleSetAsDefault = (schedule: Schedule) => { - setAsDefaultMutation(schedule.id, { - onError: () => { - showErrorAlert("Error", "Failed to set schedule as default. Please try again."); - }, - }); - }; - - const handleDuplicate = (schedule: Schedule) => { - duplicateScheduleMutation(schedule.id, { - onError: () => { - showErrorAlert("Error", "Failed to duplicate schedule. Please try again."); - }, - }); - }; - - const handleDelete = (schedule: Schedule) => { - if (Platform.OS === "web") { - // Use custom modal for web - setShowDeleteModal(true); - } else { - // Use native Alert for iOS/Android - Alert.alert("Delete Schedule", `Are you sure you want to delete "${schedule.name}"?`, [ - { text: "Cancel", style: "cancel" }, - { - text: "Delete", - style: "destructive", - onPress: () => { - deleteScheduleMutation(schedule.id, { - onError: () => { - showErrorAlert("Error", "Failed to delete schedule. Please try again."); - }, - }); - }, - }, - ]); - } - }; - - const confirmDelete = () => { - if (!selectedSchedule) return; - - deleteScheduleMutation(selectedSchedule.id, { - onSuccess: () => { - setShowDeleteModal(false); - setSelectedSchedule(null); - }, - onError: () => { - showErrorAlert("Error", "Failed to delete schedule. Please try again."); - }, - }); - }; - - const handleSchedulePress = (schedule: Schedule) => { - router.push({ - pathname: "/availability-detail", - params: { id: schedule.id.toString() }, - }); - }; const handleCreateNew = () => { - setNewScheduleName(""); setShowCreateModal(true); }; - const handleCreateSchedule = async () => { - if (!newScheduleName.trim()) { - Alert.alert("Error", "Please enter a schedule name"); - return; - } - - // Get user's timezone (default to America/New_York if not available) - let userTimezone = "America/New_York"; - try { - const userProfile = await CalComAPIService.getUserProfile(); - if (userProfile.timeZone) { - userTimezone = userProfile.timeZone; - } - } catch (error) { - console.log("Could not get user timezone, using default"); - } - - // Create schedule with Monday-Friday 9 AM - 5 PM default - createScheduleMutation( - { - name: newScheduleName.trim(), - timeZone: userTimezone, - isDefault: false, - availability: [ - { - days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], - startTime: "09:00", - endTime: "17:00", - }, - ], - }, - { - onSuccess: (newSchedule) => { - setShowCreateModal(false); - setNewScheduleName(""); - - // Navigate to edit the newly created schedule - router.push({ - pathname: "/availability-detail", - params: { - id: newSchedule.id.toString(), - }, - }); - }, - onError: (error) => { - console.error("Failed to create schedule:", error); - showErrorAlert("Error", "Failed to create schedule. Please try again."); - }, - } - ); - }; - - if (loading) { - return ( - -
- - - - - ); - } - - if (error) { - return ( - -
- - - - Unable to load availability - - {error} - refetch()}> - Retry - - - - ); - } - - // Determine what content to show - const showEmptyState = schedules.length === 0 && !loading; - const showSearchEmptyState = - filteredSchedules.length === 0 && searchQuery.trim() !== "" && !showEmptyState; - const showList = !showEmptyState && !showSearchEmptyState; - return ( <> - -
- - - - - New - - - - - {/* Empty state - no schedules */} - - } - contentInsetAdjustmentBehavior="automatic" - > - - - - - {/* Search bar and content for non-empty states */} - - {/* Search empty state */} - - } - > - - - - - {/* Schedules list */} - - item.id.toString()} - renderItem={({ item, index }) => ( - - )} - refreshControl={} - showsVerticalScrollIndicator={false} - contentInsetAdjustmentBehavior="automatic" - /> - - - - {/* Create Schedule Modal */} - setShowCreateModal(false)} - > - setShowCreateModal(false)} - > - e.stopPropagation()} - style={shadows.xl()} - > - {/* Header */} - - Add a new schedule - - - {/* Content */} - - - Name - - - - - {/* Footer */} - - - { - setShowCreateModal(false); - setNewScheduleName(""); - }} - disabled={creating} - > - Close - - - Continue - - - - - - - - {/* Schedule Actions Modal */} - setShowActionsModal(false)} - > - setShowActionsModal(false)} - > - e.stopPropagation()} - > - {/* Header */} - - - Schedule Actions - - - - {/* Actions List */} - - {/* Set as Default - only show if not already default */} - - <> - { - setShowActionsModal(false); - if (selectedSchedule) { - handleSetAsDefault(selectedSchedule); - } - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - Set as Default - - - - - - - {/* Duplicate */} - { - setShowActionsModal(false); - if (selectedSchedule) { - setTimeout(() => { - handleDuplicate(selectedSchedule); - }, 100); - } - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - Duplicate - - - {/* Separator */} - - - {/* Delete */} - { - setShowActionsModal(false); - if (selectedSchedule) { - // Small delay to ensure modal closes before alert shows - setTimeout(() => { - handleDelete(selectedSchedule); - }, 100); - } - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - Delete - - - - {/* Cancel button */} - - setShowActionsModal(false)} - > - Cancel - - - - - - - {/* Delete Confirmation Modal */} - !deleting && setShowDeleteModal(false)} - > - - - {/* Icon */} - - - - - - - {/* Title */} - - Delete Schedule - - - {/* Description */} - - Are you sure you want to delete "{selectedSchedule?.name}"? This action cannot be - undone. - - - {/* Buttons */} - - setShowDeleteModal(false)} - disabled={deleting} - > - Cancel - - - Delete - - - - - + ); } diff --git a/companion/app/(tabs)/(event-types)/index.tsx b/companion/app/(tabs)/(event-types)/index.tsx index 5a82fdea97..0533e6c46d 100644 --- a/companion/app/(tabs)/(event-types)/index.tsx +++ b/companion/app/(tabs)/(event-types)/index.tsx @@ -243,7 +243,12 @@ export default function EventTypes() { } }, onError: (error) => { - console.error("Failed to delete event type:", error); + const message = error instanceof Error ? error.message : String(error); + console.error("Failed to delete event type", message); + if (__DEV__) { + const stack = error instanceof Error ? error.stack : undefined; + console.debug("[EventTypes] deleteEventType failed", { message, stack }); + } if (Platform.OS === "web") { showToastMessage("Failed to delete event type"); } else { @@ -283,7 +288,12 @@ export default function EventTypes() { }); }, onError: (error) => { - console.error("Failed to duplicate event type:", error); + const message = error instanceof Error ? error.message : String(error); + console.error("Failed to duplicate event type", message); + if (__DEV__) { + const stack = error instanceof Error ? error.stack : undefined; + console.debug("[EventTypes] duplicateEventType failed", { message, stack }); + } if (Platform.OS === "web") { showToastMessage("Failed to duplicate event type"); } else { @@ -305,7 +315,7 @@ export default function EventTypes() { await openInAppBrowser(link, "event type preview"); } } catch (error) { - console.error("Failed to open preview:", error); + console.error("Failed to open preview"); if (Platform.OS === "web") { showToastMessage("Failed to open preview"); } else { @@ -373,7 +383,12 @@ export default function EventTypes() { }); }, onError: (error) => { - console.error("Failed to create event type:", error); + const message = error instanceof Error ? error.message : String(error); + console.error("Failed to create event type", message); + if (__DEV__) { + const stack = error instanceof Error ? error.stack : undefined; + console.debug("[EventTypes] createEventType failed", { message, stack }); + } showErrorAlert("Error", "Failed to create event type. Please try again."); }, } diff --git a/companion/app/(tabs)/more.tsx b/companion/app/(tabs)/more.tsx index 31da2a6c64..71032856e4 100644 --- a/companion/app/(tabs)/more.tsx +++ b/companion/app/(tabs)/more.tsx @@ -25,7 +25,12 @@ export default function More() { try { await logout(); } catch (error) { - console.error("Logout error:", error); + const message = error instanceof Error ? error.message : String(error); + console.error("Logout error", message); + if (__DEV__) { + const stack = error instanceof Error ? error.stack : undefined; + console.debug("[More] logout failed", { message, stack }); + } showErrorAlert("Error", "Failed to sign out. Please try again."); } }; diff --git a/companion/app/availability-detail.tsx b/companion/app/availability-detail.tsx index 42aa63d0fb..65bd0629be 100644 --- a/companion/app/availability-detail.tsx +++ b/companion/app/availability-detail.tsx @@ -1,995 +1,18 @@ -import { Ionicons } from "@expo/vector-icons"; -import { GlassView } from "expo-glass-effect"; -import { useRouter, useLocalSearchParams, Stack } from "expo-router"; -import React, { useState, useEffect } from "react"; -import { - View, - Text, - ScrollView, - TouchableOpacity, - TextInput, - Alert, - ActivityIndicator, - Switch, -} from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; - -import { CalComAPIService, Schedule } from "../services/calcom"; -import { ScheduleAvailability } from "../services/types"; -import { FullScreenModal } from "../components/FullScreenModal"; -import { showErrorAlert } from "../utils/alerts"; - -const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; -const DAY_ABBREVIATIONS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; - -// Convert 24-hour time to 12-hour format with AM/PM -const formatTime12Hour = (time24: string): string => { - const [hours, minutes] = time24.split(":"); - const hour = parseInt(hours, 10); - const min = minutes || "00"; - const period = hour >= 12 ? "PM" : "AM"; - const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; - // Pad single-digit hours with leading zero for consistent width - const hour12Padded = hour12.toString().padStart(2, "0"); - return `${hour12Padded}:${min} ${period}`; -}; - -// Format availability for display -const formatAvailabilityDisplay = ( - availability: Record -): string[] => { - // Group slots by time range - const timeRangeMap: Record = {}; - - Object.keys(availability).forEach((dayIndexStr) => { - const dayIndex = Number(dayIndexStr); - const slots = availability[dayIndex]; - if (slots && slots.length > 0) { - slots.forEach((slot) => { - const timeKey = `${slot.startTime}-${slot.endTime}`; - if (!timeRangeMap[timeKey]) { - timeRangeMap[timeKey] = []; - } - timeRangeMap[timeKey].push(dayIndex); - }); - } - }); - - // Format each time range group - const formatted: string[] = []; - Object.keys(timeRangeMap).forEach((timeKey) => { - const days = timeRangeMap[timeKey].sort((a, b) => a - b); - const [startTime, endTime] = timeKey.split("-"); - const dayNames = days.map((day) => DAY_ABBREVIATIONS[day]).join(", "); - const timeRange = `${formatTime12Hour(startTime)} - ${formatTime12Hour(endTime)}`; - formatted.push(`${dayNames}, ${timeRange}`); - }); - - return formatted; -}; - -// Generate time options (15-minute intervals) -const generateTimeOptions = () => { - const options: string[] = []; - for (let hour = 0; hour < 24; hour++) { - for (let minute = 0; minute < 60; minute += 15) { - const h = hour.toString().padStart(2, "0"); - const m = minute.toString().padStart(2, "0"); - options.push(`${h}:${m}`); - } - } - return options; -}; - -const TIME_OPTIONS = generateTimeOptions(); +import { AvailabilityDetailScreen } from "../components/screens/AvailabilityDetailScreen"; +import { useLocalSearchParams, Stack } from "expo-router"; +import React from "react"; export default function AvailabilityDetail() { - const router = useRouter(); const { id } = useLocalSearchParams<{ id: string }>(); - const insets = useSafeAreaInsets(); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [schedule, setSchedule] = useState(null); - const [scheduleName, setScheduleName] = useState(""); - const [timeZone, setTimeZone] = useState(""); - const [isDefault, setIsDefault] = useState(false); - const [availability, setAvailability] = useState>({}); - const [showTimezoneModal, setShowTimezoneModal] = useState(false); - const [showTimePicker, setShowTimePicker] = useState<{ - dayIndex: number; - slotIndex: number; - type: "start" | "end"; - } | null>(null); - const [overrides, setOverrides] = useState< - Array<{ - date: string; // Format: "2024-05-20" - startTime: string; // Format: "12:00" or "00:00" for unavailable - endTime: string; // Format: "14:00" or "00:00" for unavailable - }> - >([]); - const [showOverrideModal, setShowOverrideModal] = useState(false); - const [editingOverride, setEditingOverride] = useState(null); - const [overrideDate, setOverrideDate] = useState(""); - const [overrideStartTime, setOverrideStartTime] = useState("09:00"); - const [overrideEndTime, setOverrideEndTime] = useState("17:00"); - const [isUnavailable, setIsUnavailable] = useState(false); - const [showOverrideTimePicker, setShowOverrideTimePicker] = useState<{ - type: "start" | "end"; - } | null>(null); - - // Common timezones - const timezones = [ - "America/New_York", - "America/Chicago", - "America/Denver", - "America/Los_Angeles", - "Europe/London", - "Europe/Paris", - "Europe/Berlin", - "Asia/Tokyo", - "Asia/Shanghai", - "Asia/Kolkata", - "Australia/Sydney", - "UTC", - ]; - - useEffect(() => { - if (id) { - fetchSchedule(); - } - }, [id]); - - const fetchSchedule = async () => { - try { - setLoading(true); - const scheduleData = await CalComAPIService.getScheduleById(Number(id)); - - if (scheduleData) { - setSchedule(scheduleData); - setScheduleName(scheduleData.name || ""); - setTimeZone(scheduleData.timeZone || "UTC"); - setIsDefault(scheduleData.isDefault || false); - - // Convert availability array to day-indexed object - const availabilityMap: Record = {}; - - // Map day names to numbers - const dayNameToNumber: Record = { - Sunday: 0, - Monday: 1, - Tuesday: 2, - Wednesday: 3, - Thursday: 4, - Friday: 5, - Saturday: 6, - }; - - if (scheduleData.availability && Array.isArray(scheduleData.availability)) { - scheduleData.availability.forEach((slot) => { - // Handle both string day names and number day formats - let days: number[] = []; - if (Array.isArray(slot.days)) { - days = slot.days - .map((day) => { - // If it's a day name string (e.g., "Sunday", "Monday") - if (typeof day === "string" && dayNameToNumber[day] !== undefined) { - return dayNameToNumber[day]; - } - // If it's a number string (e.g., "0", "1") - if (typeof day === "string") { - const parsed = parseInt(day, 10); - if (!isNaN(parsed) && parsed >= 0 && parsed <= 6) { - return parsed; - } - } - // If it's already a number - 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] = []; - } - availabilityMap[day].push({ - days: [day.toString()], - startTime: slot.startTime || "09:00:00", - endTime: slot.endTime || "17:00:00", - }); - }); - }); - } - - setAvailability(availabilityMap); - - // Load overrides if they exist - if (scheduleData.overrides && Array.isArray(scheduleData.overrides)) { - const formattedOverrides = scheduleData.overrides.map((override) => ({ - date: override.date || "", - startTime: override.startTime || "00:00", - endTime: override.endTime || "00:00", - })); - setOverrides(formattedOverrides); - } else { - setOverrides([]); - } - } - } catch (error) { - console.error("Error fetching schedule:", error); - showErrorAlert("Error", "Failed to load schedule. Please try again."); - router.back(); - } finally { - setLoading(false); - } - }; - - const toggleDay = (dayIndex: number) => { - setAvailability((prev) => { - const newAvailability = { ...prev }; - if (newAvailability[dayIndex] && newAvailability[dayIndex].length > 0) { - // Disable day - remove availability - delete newAvailability[dayIndex]; - } else { - // Enable day - add default time slot (9 AM - 5 PM) - newAvailability[dayIndex] = [ - { - days: [dayIndex.toString()], - startTime: "09:00:00", - endTime: "17:00:00", - }, - ]; - } - return newAvailability; - }); - }; - - const addTimeSlot = (dayIndex: number) => { - setAvailability((prev) => { - const newAvailability = { ...prev }; - if (!newAvailability[dayIndex]) { - newAvailability[dayIndex] = []; - } - newAvailability[dayIndex].push({ - days: [dayIndex.toString()], - startTime: "09:00:00", - endTime: "17:00:00", - }); - return newAvailability; - }); - }; - - const removeTimeSlot = (dayIndex: number, slotIndex: number) => { - setAvailability((prev) => { - const newAvailability = { ...prev }; - if (newAvailability[dayIndex]) { - newAvailability[dayIndex] = newAvailability[dayIndex].filter((_, i) => i !== slotIndex); - if (newAvailability[dayIndex].length === 0) { - delete newAvailability[dayIndex]; - } - } - return newAvailability; - }); - }; - - const updateTimeSlot = ( - dayIndex: number, - slotIndex: number, - type: "start" | "end", - time: string - ) => { - setAvailability((prev) => { - const newAvailability = { ...prev }; - if (newAvailability[dayIndex] && newAvailability[dayIndex][slotIndex]) { - const slot = { ...newAvailability[dayIndex][slotIndex] }; - if (type === "start") { - slot.startTime = `${time}:00`; - } else { - slot.endTime = `${time}:00`; - } - newAvailability[dayIndex][slotIndex] = slot; - } - return newAvailability; - }); - }; - - const handleSave = async () => { - try { - setSaving(true); - - // Convert availability object back to array format with day names - const availabilityArray: Array<{ - days: string[]; // Day names like "Monday", "Tuesday" - startTime: string; // Format: "09:00" - endTime: string; // Format: "10:00" - }> = []; - - Object.keys(availability).forEach((dayIndexStr) => { - const dayIndex = Number(dayIndexStr); - const slots = availability[dayIndex]; - if (slots && slots.length > 0) { - slots.forEach((slot) => { - // Convert day number to day name - const dayName = DAYS[dayIndex]; - - // Format time from "09:00:00" to "09:00" (remove seconds) - const formatTimeForAPI = (time: string): string => { - return time.substring(0, 5); // Take only HH:MM - }; - - availabilityArray.push({ - days: [dayName], - startTime: formatTimeForAPI(slot.startTime), - endTime: formatTimeForAPI(slot.endTime), - }); - }); - } - }); - - // Format overrides for API (ensure times are in HH:MM format) - const formattedOverrides = overrides.map((override) => ({ - date: override.date, - startTime: override.startTime.substring(0, 5), // Ensure HH:MM format - endTime: override.endTime.substring(0, 5), // Ensure HH:MM format - })); - - await CalComAPIService.updateSchedule(Number(id), { - name: scheduleName, - timeZone, - availability: availabilityArray, - isDefault: isDefault, - overrides: formattedOverrides, - }); - - Alert.alert("Success", "Schedule updated successfully", [ - { text: "OK", onPress: () => router.back() }, - ]); - } catch (error) { - showErrorAlert("Error", "Failed to update schedule. Please try again."); - } finally { - setSaving(false); - } - }; - - const handleSetAsDefault = async () => { - try { - await CalComAPIService.updateSchedule(Number(id), { - isDefault: true, - }); - setIsDefault(true); - Alert.alert("Success", "Schedule set as default successfully"); - } catch (error) { - showErrorAlert("Error", "Failed to set schedule as default. Please try again."); - } - }; - - const handleDelete = () => { - Alert.alert("Delete Schedule", `Are you sure you want to delete "${scheduleName}"?`, [ - { text: "Cancel", style: "cancel" }, - { - text: "Delete", - style: "destructive", - onPress: async () => { - try { - await CalComAPIService.deleteSchedule(Number(id)); - Alert.alert("Success", "Schedule deleted successfully", [ - { text: "OK", onPress: () => router.back() }, - ]); - } catch (error) { - showErrorAlert("Error", "Failed to delete schedule. Please try again."); - } - }, - }, - ]); - }; - - const handleAddOverride = () => { - setEditingOverride(null); - setOverrideDate(""); - setOverrideStartTime("09:00"); - setOverrideEndTime("17:00"); - setIsUnavailable(false); - setShowOverrideModal(true); - }; - - const handleEditOverride = (index: number) => { - const override = overrides[index]; - setEditingOverride(index); - setOverrideDate(override.date); - setOverrideStartTime(override.startTime); - setOverrideEndTime(override.endTime); - setIsUnavailable(override.startTime === "00:00" && override.endTime === "00:00"); - setShowOverrideModal(true); - }; - - const handleDeleteOverride = (index: number) => { - Alert.alert("Delete Override", "Are you sure you want to delete this date override?", [ - { text: "Cancel", style: "cancel" }, - { - text: "Delete", - style: "destructive", - onPress: () => { - setOverrides(overrides.filter((_, i) => i !== index)); - }, - }, - ]); - }; - - const handleSaveOverride = () => { - if (!overrideDate) { - Alert.alert("Error", "Please select a date"); - return; - } - - const newOverride = { - date: overrideDate, - startTime: isUnavailable ? "00:00" : overrideStartTime, - endTime: isUnavailable ? "00:00" : overrideEndTime, - }; - - if (editingOverride !== null) { - // Update existing override - const updated = [...overrides]; - updated[editingOverride] = newOverride; - setOverrides(updated); - } else { - // Add new override - setOverrides([...overrides, newOverride]); - } - - setShowOverrideModal(false); - }; - - const formatDateForDisplay = (dateStr: string): string => { - if (!dateStr) return ""; - const date = new Date(dateStr + "T00:00:00"); - return date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); - }; - - if (loading) { - return ( - - - Loading schedule... - - ); + if (!id) { + return null; } return ( <> - ( - Edit Availability - ), - headerLeft: () => ( - router.back()} className="ml-4"> - - - ), - headerRight: () => ( - - Save - - ), - }} - /> - - - - {/* Schedule Name and Working Hours Display */} - - {scheduleName} - {Object.keys(availability).length > 0 ? ( - - {formatAvailabilityDisplay(availability).map((line, index) => ( - - {line} - - ))} - - ) : ( - No availability set - )} - - - {/* Availability Schedule */} - - Availability - {DAYS.map((day, dayIndex) => { - const daySlots = availability[dayIndex] || []; - const isEnabled = daySlots.length > 0; - const firstSlot = daySlots[0]; - - return ( - - - - toggleDay(dayIndex)} - trackColor={{ false: "#E5E5EA", true: "#34C759" }} - thumbColor="#fff" - style={{ transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }] }} - /> - - {DAY_ABBREVIATIONS[dayIndex]} - - {isEnabled && firstSlot ? ( - - - setShowTimePicker({ dayIndex, slotIndex: 0, type: "start" }) - } - className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1" - style={{ width: 85 }} - > - - {formatTime12Hour(firstSlot.startTime)} - - - - - - setShowTimePicker({ dayIndex, slotIndex: 0, type: "end" }) - } - className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1" - style={{ width: 85 }} - > - - {formatTime12Hour(firstSlot.endTime)} - - - - ) : null} - - {isEnabled ? ( - addTimeSlot(dayIndex)} className="p-1"> - - - ) : null} - - - {isEnabled && daySlots.length > 1 ? ( - - {daySlots.slice(1).map((slot, slotIndex) => ( - - - setShowTimePicker({ - dayIndex, - slotIndex: slotIndex + 1, - type: "start", - }) - } - className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1" - style={{ width: 85 }} - > - - {formatTime12Hour(slot.startTime)} - - - - - - setShowTimePicker({ - dayIndex, - slotIndex: slotIndex + 1, - type: "end", - }) - } - className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1" - style={{ width: 85 }} - > - - {formatTime12Hour(slot.endTime)} - - - removeTimeSlot(dayIndex, slotIndex + 1)} - className="p-1" - > - - - - ))} - - ) : null} - - ); - })} - - - {/* Timezone */} - - Timezone - setShowTimezoneModal(true)} - className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3" - > - {timeZone} - - - - - {/* Date Overrides */} - - Date overrides - - Add dates when your availability changes from your daily hours. - - - {overrides.length > 0 ? ( - - {overrides.map((override, index) => ( - - - - {formatDateForDisplay(override.date)} - - {override.startTime === "00:00" && override.endTime === "00:00" ? ( - Unavailable (All day) - ) : ( - - {formatTime12Hour(override.startTime + ":00")} -{" "} - {formatTime12Hour(override.endTime + ":00")} - - )} - - - handleEditOverride(index)} className="p-2"> - - - handleDeleteOverride(index)} - className="p-2" - > - - - - - ))} - - ) : null} - - - - Add an override - - - - - - {/* Bottom Action Bar */} - - - - Set as Default - - - - - - - - - - - - - - - {/* Timezone Modal */} - setShowTimezoneModal(false)} - > - setShowTimezoneModal(false)} - > - e.stopPropagation()} - > - - Select Timezone - setShowTimezoneModal(false)}> - - - - - {timezones.map((tz) => ( - { - setTimeZone(tz); - setShowTimezoneModal(false); - }} - className="border-b border-[#E5E5EA] py-3" - > - {tz} - - ))} - - - - - - {/* Time Picker Modal */} - setShowTimePicker(null)} - > - setShowTimePicker(null)} - > - e.stopPropagation()} - > - - - Select {showTimePicker?.type === "start" ? "Start" : "End"} Time - - setShowTimePicker(null)}> - - - - - {TIME_OPTIONS.map((time) => { - const currentTime = - showTimePicker?.type === "start" - ? availability[showTimePicker.dayIndex]?.[ - showTimePicker.slotIndex - ]?.startTime.substring(0, 5) - : availability[showTimePicker?.dayIndex || 0]?.[ - showTimePicker?.slotIndex || 0 - ]?.endTime.substring(0, 5); - const isSelected = currentTime === time; - - return ( - { - if (showTimePicker) { - updateTimeSlot( - showTimePicker.dayIndex, - showTimePicker.slotIndex, - showTimePicker.type, - time - ); - setShowTimePicker(null); - } - }} - className={`border-b border-[#E5E5EA] py-3 ${isSelected ? "bg-[#E3F2FD]" : ""}`} - > - - {time} - - - ); - })} - - - - - - {/* Override Modal */} - setShowOverrideModal(false)} - > - setShowOverrideModal(false)} - > - e.stopPropagation()} - > - - - {editingOverride !== null ? "Edit Override" : "Add Override"} - - setShowOverrideModal(false)}> - - - - - - - {/* Date Picker */} - - - Select the dates to override - - - Enter date in YYYY-MM-DD format - - - {/* Unavailable Toggle */} - - - Mark unavailable (All day) - - - - - {/* Time Picker (only if not unavailable) */} - {!isUnavailable ? ( - - - Which hours are you free? - - - setShowOverrideTimePicker({ type: "start" })} - className="flex-1 rounded-lg border border-[#E5E5EA] bg-white px-3 py-3" - > - - {formatTime12Hour(overrideStartTime + ":00")} - - - - - setShowOverrideTimePicker({ type: "end" })} - className="flex-1 rounded-lg border border-[#E5E5EA] bg-white px-3 py-3" - > - - {formatTime12Hour(overrideEndTime + ":00")} - - - - - ) : null} - - {/* Save Button */} - - - Save Override - - - - - - - - - {/* Override Time Picker Modal */} - setShowOverrideTimePicker(null)} - > - setShowOverrideTimePicker(null)} - > - e.stopPropagation()} - > - - - Select {showOverrideTimePicker?.type === "start" ? "Start" : "End"} Time - - setShowOverrideTimePicker(null)}> - - - - - {TIME_OPTIONS.map((time) => { - const currentTime = - showOverrideTimePicker?.type === "start" ? overrideStartTime : overrideEndTime; - const isSelected = currentTime === time; - return ( - { - if (showOverrideTimePicker?.type === "start") { - setOverrideStartTime(time); - } else { - setOverrideEndTime(time); - } - setShowOverrideTimePicker(null); - }} - className={`border-b border-[#E5E5EA] py-3 ${isSelected ? "bg-[#F0F9FF]" : ""}`} - > - - {formatTime12Hour(time + ":00")} - - - ); - })} - - - - + + ); } diff --git a/companion/app/booking-detail.tsx b/companion/app/booking-detail.tsx index 0425bf6351..146b2c3c5f 100644 --- a/companion/app/booking-detail.tsx +++ b/companion/app/booking-detail.tsx @@ -1,683 +1,18 @@ -import { Ionicons } from "@expo/vector-icons"; -import { useRouter, useLocalSearchParams, Stack } from "expo-router"; -import React, { useState, useEffect } from "react"; -import { - View, - Text, - ScrollView, - TouchableOpacity, - Alert, - ActivityIndicator, - TextInput, - Platform, -} from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { CalComAPIService, Booking } from "../services/calcom"; -import { showErrorAlert } from "../utils/alerts"; -import { openInAppBrowser } from "../utils/browser"; -import { shadows } from "../utils/shadows"; -import { SvgImage } from "../components/SvgImage"; -import { FullScreenModal } from "../components/FullScreenModal"; -import { BookingActionsModal } from "../components/BookingActionsModal"; -import { getAppIconUrl } from "../utils/getAppIconUrl"; -import { getDefaultLocationIconUrl, defaultLocations } from "../utils/defaultLocations"; -import { formatAppIdToDisplayName } from "../utils/formatters"; - -// Format date: "Tuesday, November 25, 2025" -const formatDateFull = (dateString: string): string => { - if (!dateString) return ""; - try { - const date = new Date(dateString); - return date.toLocaleDateString("en-US", { - weekday: "long", - month: "long", - day: "numeric", - year: "numeric", - }); - } catch (error) { - return ""; - } -}; - -// Format time: "9:40pm - 10:00pm" -const formatTime12Hour = (dateString: string): string => { - if (!dateString) return ""; - try { - const date = new Date(dateString); - const hours = date.getHours(); - const minutes = date.getMinutes(); - const period = hours >= 12 ? "pm" : "am"; - const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours; - const minStr = minutes.toString().padStart(2, "0"); - return `${hour12}:${minStr}${period}`; - } catch (error) { - return ""; - } -}; - -// Get timezone from date string -const getTimezone = (dateString: string): string => { - if (!dateString) return ""; - try { - const date = new Date(dateString); - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - return timeZone; - } catch (error) { - return ""; - } -}; - -// Get initials from a name (e.g., "Keith Williams" -> "KW", "Dhairyashil Shinde" -> "DS") -const getInitials = (name: string): string => { - if (!name) return ""; - const parts = name.trim().split(/\s+/); - if (parts.length === 0) return ""; - if (parts.length === 1) { - return parts[0].charAt(0).toUpperCase(); - } - // Get first letter of first name and first letter of last name - return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase(); -}; - -// Get location provider info -const getLocationProvider = (location: string | undefined, metadata?: Record) => { - // Check metadata for videoCallUrl first - const videoCallUrl = metadata?.videoCallUrl; - const locationToCheck = videoCallUrl || location; - - if (!locationToCheck) return null; - - // Check if it's a video call URL - if (typeof locationToCheck === "string" && locationToCheck.startsWith("http")) { - // Try to detect provider from URL - if (locationToCheck.includes("cal.com/video") || locationToCheck.includes("cal-video")) { - const iconUrl = getAppIconUrl("daily_video", "cal-video"); - return { - label: "Cal Video", - iconUrl: iconUrl || "https://app.cal.com/app-store/dailyvideo/icon.svg", - url: locationToCheck, - }; - } - // Check for other video providers by URL pattern - const videoProviders = [ - { pattern: /zoom\.us/, label: "Zoom", type: "zoom_video", appId: "zoom" }, - { - pattern: /meet\.google\.com/, - label: "Google Meet", - type: "google_video", - appId: "google-meet", - }, - { - pattern: /teams\.microsoft\.com/, - label: "Microsoft Teams", - type: "office365_video", - appId: "msteams", - }, - ]; - - for (const provider of videoProviders) { - if (provider.pattern.test(locationToCheck)) { - const iconUrl = getAppIconUrl(provider.type, provider.appId); - return { - label: provider.label, - iconUrl: iconUrl, - url: locationToCheck, - }; - } - } - - // Generic link meeting - const linkIconUrl = getDefaultLocationIconUrl("link") || "https://app.cal.com/link.svg"; - return { - label: "Link Meeting", - iconUrl: linkIconUrl, - url: locationToCheck, - }; - } - - // Check if it's an integration location (e.g., "integrations:zoom", "integrations:cal-video") - if (typeof locationToCheck === "string" && locationToCheck.startsWith("integrations:")) { - const appId = locationToCheck.replace("integrations:", ""); - const iconUrl = getAppIconUrl("", appId); - - if (iconUrl) { - return { - label: formatAppIdToDisplayName(appId), - iconUrl: iconUrl, - url: null, - }; - } - } - - // Check if it's a default location type - const defaultLocation = defaultLocations.find((loc) => loc.type === locationToCheck); - if (defaultLocation) { - return { - label: defaultLocation.label, - iconUrl: defaultLocation.iconUrl, - url: null, - }; - } - - // Fallback: return as plain text location - return { - label: locationToCheck, - iconUrl: null, - url: null, - }; -}; +import { BookingDetailScreen } from "../components/screens/BookingDetailScreen"; +import { useLocalSearchParams, Stack } from "expo-router"; +import React from "react"; export default function BookingDetail() { - const router = useRouter(); const { uid } = useLocalSearchParams<{ uid: string }>(); - const insets = useSafeAreaInsets(); - const [loading, setLoading] = useState(true); - const [booking, setBooking] = useState(null); - const [error, setError] = useState(null); - const [showActionsModal, setShowActionsModal] = useState(false); - const [showRescheduleModal, setShowRescheduleModal] = useState(false); - const [rescheduleDate, setRescheduleDate] = useState(""); - const [rescheduleTime, setRescheduleTime] = useState(""); - const [rescheduleReason, setRescheduleReason] = useState(""); - const [rescheduling, setRescheduling] = useState(false); - - useEffect(() => { - if (uid) { - fetchBooking(); - } - }, [uid]); - - const fetchBooking = async () => { - try { - setLoading(true); - setError(null); - const bookingData = await CalComAPIService.getBookingByUid(uid); - console.log("Booking data received:", JSON.stringify(bookingData, null, 2)); - console.log("User:", bookingData.user); - console.log("Hosts:", bookingData.hosts); - console.log("Attendees:", bookingData.attendees); - console.log("Recurring fields:", { - recurringEventId: bookingData.recurringEventId, - recurringBookingUid: (bookingData as any).recurringBookingUid, - metadata: (bookingData as any).metadata, - }); - setBooking(bookingData); - } catch (err) { - console.error("Error fetching booking:", err); - setError("Failed to load booking. Please try again."); - if (__DEV__) { - Alert.alert("Error", "Failed to load booking. Please try again.", [ - { text: "OK", onPress: () => router.back() }, - ]); - } else { - router.back(); - } - } finally { - setLoading(false); - } - }; - - const handleJoinMeeting = () => { - if (!booking?.location) return; - - const provider = getLocationProvider(booking.location); - if (provider?.url) { - openInAppBrowser(provider.url, "meeting link"); - } - }; - - const openRescheduleModal = () => { - if (!booking) return; - - // Pre-fill with the current booking date/time (using local timezone consistently) - const currentDate = new Date(booking.startTime); - const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, "0")}-${String(currentDate.getDate()).padStart(2, "0")}`; - const timeStr = `${String(currentDate.getHours()).padStart(2, "0")}:${String(currentDate.getMinutes()).padStart(2, "0")}`; - - setRescheduleDate(dateStr); - setRescheduleTime(timeStr); - setRescheduleReason(""); - setShowRescheduleModal(true); - }; - - const handleReschedule = async () => { - if (!booking || !rescheduleDate || !rescheduleTime) { - showErrorAlert("Error", "Please enter both date and time"); - return; - } - - // Parse the date and time - const dateTimeStr = `${rescheduleDate}T${rescheduleTime}:00`; - const newDateTime = new Date(dateTimeStr); - - // Validate the date - if (isNaN(newDateTime.getTime())) { - showErrorAlert( - "Error", - "Invalid date or time format. Please use YYYY-MM-DD for date and HH:MM for time." - ); - return; - } - - // Check if the new time is in the future - if (newDateTime <= new Date()) { - showErrorAlert("Error", "Please select a future date and time"); - return; - } - - // Convert to UTC ISO string - const startUtc = newDateTime.toISOString(); - - setRescheduling(true); - try { - await CalComAPIService.rescheduleBooking(booking.uid, { - start: startUtc, - reschedulingReason: rescheduleReason.trim() || undefined, - }); - - Alert.alert("Success", "Booking rescheduled successfully"); - setShowRescheduleModal(false); - setRescheduleDate(""); - setRescheduleTime(""); - setRescheduleReason(""); - - // Refresh booking data - await fetchBooking(); - } catch (error) { - console.error("Failed to reschedule booking:", error); - showErrorAlert("Error", "Failed to reschedule booking. Please try again."); - } finally { - setRescheduling(false); - } - }; - - if (loading) { - return ( - - - Loading booking... - - ); + if (!uid) { + return null; } - if (error || !booking) { - return ( - - - - {error || "Booking not found"} - - router.back()} - > - Go Back - - - ); - } - - const startTime = booking.start || booking.startTime || ""; - const endTime = booking.end || booking.endTime || ""; - const dateFormatted = formatDateFull(startTime); - const timeFormatted = `${formatTime12Hour(startTime)} - ${formatTime12Hour(endTime)}`; - const timezone = getTimezone(startTime); - const locationProvider = getLocationProvider(booking.location, booking.responses); - return ( <> - ( - router.back()} - className="items-center justify-center rounded-full" - style={{ width: 40, height: 40, backgroundColor: "#F0F0F0" }} - > - - - ), - headerRight: () => ( - setShowActionsModal(true)} - className="items-center justify-center rounded-full" - style={{ width: 40, height: 40, backgroundColor: "#F0F0F0" }} - > - - - ), - }} - /> - - - {/* Title */} - - {booking.title} - - {dateFormatted} {timeFormatted} ({timezone}) - - - - {/* Who Section */} - - Who - {/* Show host from user field or hosts array */} - {booking.user || (booking.hosts && booking.hosts.length > 0) ? ( - - {booking.user ? ( - - - - {getInitials(booking.user.name)} - - - - - - {booking.user.name} - - - host - - - {booking.user.email} - - - ) : booking.hosts && booking.hosts.length > 0 ? ( - booking.hosts.map((host, hostIndex) => ( - 0 ? "mt-4" : ""}`} - > - - - {getInitials(host.name || "Host")} - - - - - - {host.name || "Host"} - - - host - - - {host.email && {host.email}} - - - )) - ) : null} - - ) : null} - {booking.attendees && booking.attendees.length > 0 ? ( - - {booking.attendees.map((attendee, index) => ( - 0 ? "mt-4" : ""}`}> - - - {getInitials(attendee.name)} - - - - - {attendee.name} - - {attendee.email} - - - ))} - - ) : null} - - - {/* Where Section */} - {locationProvider ? ( - - Where - {locationProvider.url ? ( - - {locationProvider.iconUrl ? ( - - ) : null} - {locationProvider.label}: - - {locationProvider.url} - - - ) : ( - - {locationProvider.iconUrl ? ( - - ) : null} - {locationProvider.label} - - )} - - ) : null} - - {/* Recurring Event Section */} - {booking.recurringEventId || booking.recurringBookingUid ? ( - - Recurring Event - Every 2 weeks for 6 occurrences - - ) : null} - - {/* Description Section */} - {booking.description ? ( - - Description - {booking.description} - - ) : null} - - {/* Join Meeting Button */} - {locationProvider?.url ? ( - - {locationProvider.iconUrl ? ( - - ) : null} - - Join {locationProvider.label} - - - ) : null} - - - - {/* Booking Actions Modal */} - setShowActionsModal(false)} - booking={booking} - hasLocationUrl={!!locationProvider?.url} - isUpcoming={booking ? new Date(booking.startTime) > new Date() : false} - isPast={booking ? new Date(booking.startTime) <= new Date() : false} - isCancelled={booking?.status?.toUpperCase() === "CANCELLED"} - isUnconfirmed={booking?.status?.toUpperCase() === "PENDING"} - onReschedule={openRescheduleModal} - onEditLocation={() => { - Alert.alert("Edit Location", "Edit location functionality coming soon"); - }} - onAddGuests={() => { - Alert.alert("Add Guests", "Add guests functionality coming soon"); - }} - onViewRecordings={() => { - Alert.alert("View Recordings", "View recordings functionality coming soon"); - }} - onMeetingSessionDetails={() => { - Alert.alert( - "Meeting Session Details", - "Meeting session details functionality coming soon" - ); - }} - onMarkNoShow={() => { - Alert.alert("Mark as No-Show", "Mark as no-show functionality coming soon"); - }} - onReportBooking={() => { - Alert.alert("Report Booking", "Report booking functionality coming soon"); - }} - onCancelBooking={() => { - Alert.alert("Cancel Booking", "Are you sure you want to cancel this booking?", [ - { text: "No", style: "cancel" }, - { - text: "Yes, Cancel", - style: "destructive", - onPress: () => { - // TODO: Implement cancel booking - console.log("Cancel booking"); - }, - }, - ]); - }} - /> - - {/* Reschedule Modal */} - setShowRescheduleModal(false)} - > - setShowRescheduleModal(false)} - > - e.stopPropagation()} - style={shadows.xl()} - > - {/* Header */} - - - - - - - - Reschedule Booking - - - Select a new date and time for this booking. - - - - - - {/* Content */} - - {/* Date Input */} - - - New Date - (YYYY-MM-DD) - - - - - {/* Time Input */} - - - New Time - (HH:MM, 24-hour) - - - - - {/* Reason Input */} - - - Reason - (Optional) - - - - - - {/* Footer */} - - - { - setShowRescheduleModal(false); - setRescheduleDate(""); - setRescheduleTime(""); - setRescheduleReason(""); - }} - disabled={rescheduling} - > - Cancel - - - {rescheduling ? ( - - ) : ( - Reschedule - )} - - - - - - + + ); } diff --git a/companion/app/event-type-detail.tsx b/companion/app/event-type-detail.tsx index edafbfc3fc..9fe8dd4dcb 100644 --- a/companion/app/event-type-detail.tsx +++ b/companion/app/event-type-detail.tsx @@ -1678,7 +1678,7 @@ export default function EventTypeDetail() { schedulesLoading={schedulesLoading} scheduleDetailsLoading={scheduleDetailsLoading} selectedScheduleDetails={selectedScheduleDetails} - getDaySchedule={getDaySchedule} + getDaySchedules={getDaySchedule} formatTime={formatTime} selectedTimezone={selectedTimezone} /> diff --git a/companion/components/AppPressable.tsx b/companion/components/AppPressable.tsx new file mode 100644 index 0000000000..923ddcf01a --- /dev/null +++ b/companion/components/AppPressable.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { TouchableOpacity } from "react-native"; +import type { TouchableOpacityProps } from "react-native"; + +type AppPressableProps = TouchableOpacityProps & { + className?: string; +}; + +export const AppPressable = React.forwardRef< + React.ElementRef, + AppPressableProps +>(({ className, ...props }, ref) => { + return ; +}); + +AppPressable.displayName = "AppPressable"; diff --git a/companion/components/AuthTokenProvider.tsx b/companion/components/AuthTokenProvider.tsx index b6d6f0f261..28e77e4f49 100644 --- a/companion/components/AuthTokenProvider.tsx +++ b/companion/components/AuthTokenProvider.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { useAuth } from "../contexts/AuthContext"; -import { setAuthToken, setWebSession } from "../services/calcom"; +import { CalComAPIService } from "../services/calcom"; export function AuthTokenProvider({ children }: { children: React.ReactNode }) { const { accessToken, isAuthenticated, isWebSession } = useAuth(); @@ -8,12 +8,10 @@ export function AuthTokenProvider({ children }: { children: React.ReactNode }) { useEffect(() => { if (isAuthenticated) { if (accessToken) { - setAuthToken(accessToken); + CalComAPIService.setAccessToken(accessToken); } - setWebSession(isWebSession); } else { - setAuthToken(null); - setWebSession(false); + CalComAPIService.clearAuth(); } }, [isAuthenticated, accessToken, isWebSession]); diff --git a/companion/components/FullScreenModal.d.ts b/companion/components/FullScreenModal.d.ts new file mode 100644 index 0000000000..a64aae387d --- /dev/null +++ b/companion/components/FullScreenModal.d.ts @@ -0,0 +1,10 @@ +import type React from "react"; +import type { ModalProps } from "react-native"; + +export interface FullScreenModalProps extends ModalProps { + visible: boolean; + onRequestClose: () => void; + children: React.ReactNode; +} + +export declare function FullScreenModal(props: FullScreenModalProps): JSX.Element; diff --git a/companion/components/Header.tsx b/companion/components/Header.tsx index 1c967229b8..2064949a96 100644 --- a/companion/components/Header.tsx +++ b/companion/components/Header.tsx @@ -34,7 +34,12 @@ export function Header() { const profile = await CalComAPIService.getUserProfile(); setUserProfile(profile); } catch (error) { - console.error("Failed to fetch user profile:", error); + console.error("Failed to fetch user profile"); + if (__DEV__) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + console.debug("[Header] fetchUserProfile failed", { message, stack }); + } } finally { setLoading(false); } @@ -55,7 +60,12 @@ export function Header() { await Clipboard.setStringAsync(publicPageUrl); Alert.alert("Link Copied!", "Your public page link has been copied to clipboard."); } catch (error) { - console.error("Failed to copy public page link:", error); + console.error("Failed to copy public page link"); + if (__DEV__) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + console.debug("[Header] copyPublicPageLink failed", { message, stack }); + } Alert.alert("Error", "Failed to copy link. Please try again."); } }; diff --git a/companion/components/LoginScreen.tsx b/companion/components/LoginScreen.tsx index 3e3c2cbc75..218ab79887 100644 --- a/companion/components/LoginScreen.tsx +++ b/companion/components/LoginScreen.tsx @@ -14,7 +14,7 @@ export function LoginScreen() { try { await loginWithOAuth(); } catch (error) { - console.error("OAuth login error:", error); + console.error("OAuth login error"); showErrorAlert( "Login Failed", error instanceof Error diff --git a/companion/components/LogoutButton.tsx b/companion/components/LogoutButton.tsx index 14defc4fa3..5dc50e213b 100644 --- a/companion/components/LogoutButton.tsx +++ b/companion/components/LogoutButton.tsx @@ -35,7 +35,12 @@ export function LogoutButton({ className = "" }: LogoutButtonProps) { try { await logout(); } catch (error) { - console.error("Logout error:", error); + const message = error instanceof Error ? error.message : String(error); + console.error("Logout error", message); + if (__DEV__) { + const stack = error instanceof Error ? error.stack : undefined; + console.debug("[LogoutButton] logout failed", { message, stack }); + } if (Platform.OS === "web") { window.alert("Failed to sign out. Please try again."); } else { diff --git a/companion/components/event-type-detail/tabs/AvailabilityTab.tsx b/companion/components/event-type-detail/tabs/AvailabilityTab.tsx index 6b04c069a9..0202c63bc3 100644 --- a/companion/components/event-type-detail/tabs/AvailabilityTab.tsx +++ b/companion/components/event-type-detail/tabs/AvailabilityTab.tsx @@ -16,7 +16,7 @@ interface AvailabilityTabProps { schedulesLoading: boolean; scheduleDetailsLoading: boolean; selectedScheduleDetails: any; - getDaySchedule: () => DaySchedule[]; + getDaySchedules: () => DaySchedule[]; formatTime: (time: string) => string; selectedTimezone: string; } @@ -60,7 +60,7 @@ export function AvailabilityTab(props: AvailabilityTabProps) { Loading schedule details... ) : props.selectedScheduleDetails ? ( - props.getDaySchedule().map((dayInfo, index) => ( + props.getDaySchedules().map((dayInfo, index) => ( { + const [hours, minutes] = time24.split(":"); + const hour = parseInt(hours, 10); + const min = minutes || "00"; + const period = hour >= 12 ? "PM" : "AM"; + const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + // Pad single-digit hours with leading zero for consistent width + const hour12Padded = hour12.toString().padStart(2, "0"); + return `${hour12Padded}:${min} ${period}`; +}; + +// Format availability for display +const formatAvailabilityDisplay = ( + availability: Record +): string[] => { + // Group slots by time range + const timeRangeMap: Record = {}; + + Object.keys(availability).forEach((dayIndexStr) => { + const dayIndex = Number(dayIndexStr); + const slots = availability[dayIndex]; + if (slots && slots.length > 0) { + slots.forEach((slot) => { + const timeKey = `${slot.startTime}-${slot.endTime}`; + if (!timeRangeMap[timeKey]) { + timeRangeMap[timeKey] = []; + } + timeRangeMap[timeKey].push(dayIndex); + }); + } + }); + + // Format each time range group + const formatted: string[] = []; + Object.keys(timeRangeMap).forEach((timeKey) => { + const days = timeRangeMap[timeKey].sort((a, b) => a - b); + const [startTime, endTime] = timeKey.split("-"); + const dayNames = days.map((day) => DAY_ABBREVIATIONS[day]).join(", "); + const timeRange = `${formatTime12Hour(startTime)} - ${formatTime12Hour(endTime)}`; + formatted.push(`${dayNames}, ${timeRange}`); + }); + + return formatted; +}; + +// Generate time options (15-minute intervals) +const generateTimeOptions = () => { + const options: string[] = []; + for (let hour = 0; hour < 24; hour++) { + for (let minute = 0; minute < 60; minute += 15) { + const h = hour.toString().padStart(2, "0"); + const m = minute.toString().padStart(2, "0"); + options.push(`${h}:${m}`); + } + } + return options; +}; + +const TIME_OPTIONS = generateTimeOptions(); + +export interface AvailabilityDetailScreenProps { + id: string; +} + +export function AvailabilityDetailScreen({ id }: AvailabilityDetailScreenProps) { + const router = useRouter(); + const insets = useSafeAreaInsets(); + + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [schedule, setSchedule] = useState(null); + const [scheduleName, setScheduleName] = useState(""); + const [timeZone, setTimeZone] = useState(""); + const [isDefault, setIsDefault] = useState(false); + const [availability, setAvailability] = useState>({}); + const [showTimezoneModal, setShowTimezoneModal] = useState(false); + const [showTimePicker, setShowTimePicker] = useState<{ + dayIndex: number; + slotIndex: number; + type: "start" | "end"; + } | null>(null); + const [overrides, setOverrides] = useState< + Array<{ + date: string; // Format: "2024-05-20" + startTime: string; // Format: "12:00" or "00:00" for unavailable + endTime: string; // Format: "14:00" or "00:00" for unavailable + }> + >([]); + const [showOverrideModal, setShowOverrideModal] = useState(false); + const [editingOverride, setEditingOverride] = useState(null); + const [overrideDate, setOverrideDate] = useState(""); + const [overrideStartTime, setOverrideStartTime] = useState("09:00"); + const [overrideEndTime, setOverrideEndTime] = useState("17:00"); + const [isUnavailable, setIsUnavailable] = useState(false); + const [showOverrideTimePicker, setShowOverrideTimePicker] = useState<{ + type: "start" | "end"; + } | null>(null); + + // Common timezones + const timezones = [ + "America/New_York", + "America/Chicago", + "America/Denver", + "America/Los_Angeles", + "Europe/London", + "Europe/Paris", + "Europe/Berlin", + "Asia/Tokyo", + "Asia/Shanghai", + "Asia/Kolkata", + "Australia/Sydney", + "UTC", + ]; + + useEffect(() => { + if (id) { + fetchSchedule(); + } + }, [id]); + + const fetchSchedule = async () => { + try { + setLoading(true); + const scheduleData = await CalComAPIService.getScheduleById(Number(id)); + + if (scheduleData) { + setSchedule(scheduleData); + setScheduleName(scheduleData.name || ""); + setTimeZone(scheduleData.timeZone || "UTC"); + setIsDefault(scheduleData.isDefault || false); + + // Convert availability array to day-indexed object + const availabilityMap: Record = {}; + + // Map day names to numbers + const dayNameToNumber: Record = { + Sunday: 0, + Monday: 1, + Tuesday: 2, + Wednesday: 3, + Thursday: 4, + Friday: 5, + Saturday: 6, + }; + + if (scheduleData.availability && Array.isArray(scheduleData.availability)) { + scheduleData.availability.forEach((slot) => { + // Handle both string day names and number day formats + let days: number[] = []; + if (Array.isArray(slot.days)) { + days = slot.days + .map((day) => { + // If it's a day name string (e.g., "Sunday", "Monday") + if (typeof day === "string" && dayNameToNumber[day] !== undefined) { + return dayNameToNumber[day]; + } + // If it's a number string (e.g., "0", "1") + if (typeof day === "string") { + const parsed = parseInt(day, 10); + if (!isNaN(parsed) && parsed >= 0 && parsed <= 6) { + return parsed; + } + } + // If it's already a number + 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] = []; + } + availabilityMap[day].push({ + days: [day.toString()], + startTime: slot.startTime || "09:00:00", + endTime: slot.endTime || "17:00:00", + }); + }); + }); + } + + setAvailability(availabilityMap); + + // Load overrides if they exist + if (scheduleData.overrides && Array.isArray(scheduleData.overrides)) { + const formattedOverrides = scheduleData.overrides.map((override) => ({ + date: override.date || "", + startTime: override.startTime || "00:00", + endTime: override.endTime || "00:00", + })); + setOverrides(formattedOverrides); + } else { + setOverrides([]); + } + } + } catch (error) { + // Avoid logging raw error objects from API calls (may contain sensitive response data). + 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 schedule. Please try again."); + router.back(); + } finally { + setLoading(false); + } + }; + + const toggleDay = (dayIndex: number) => { + setAvailability((prev) => { + const newAvailability = { ...prev }; + if (newAvailability[dayIndex] && newAvailability[dayIndex].length > 0) { + // Disable day - remove availability + delete newAvailability[dayIndex]; + } else { + // Enable day - add default time slot (9 AM - 5 PM) + newAvailability[dayIndex] = [ + { + days: [dayIndex.toString()], + startTime: "09:00:00", + endTime: "17:00:00", + }, + ]; + } + return newAvailability; + }); + }; + + const addTimeSlot = (dayIndex: number) => { + setAvailability((prev) => { + const newAvailability = { ...prev }; + if (!newAvailability[dayIndex]) { + newAvailability[dayIndex] = []; + } + newAvailability[dayIndex].push({ + days: [dayIndex.toString()], + startTime: "09:00:00", + endTime: "17:00:00", + }); + return newAvailability; + }); + }; + + const removeTimeSlot = (dayIndex: number, slotIndex: number) => { + setAvailability((prev) => { + const newAvailability = { ...prev }; + if (newAvailability[dayIndex]) { + newAvailability[dayIndex] = newAvailability[dayIndex].filter((_, i) => i !== slotIndex); + if (newAvailability[dayIndex].length === 0) { + delete newAvailability[dayIndex]; + } + } + return newAvailability; + }); + }; + + const updateTimeSlot = ( + dayIndex: number, + slotIndex: number, + type: "start" | "end", + time: string + ) => { + setAvailability((prev) => { + const newAvailability = { ...prev }; + if (newAvailability[dayIndex] && newAvailability[dayIndex][slotIndex]) { + const slot = { ...newAvailability[dayIndex][slotIndex] }; + if (type === "start") { + slot.startTime = `${time}:00`; + } else { + slot.endTime = `${time}:00`; + } + newAvailability[dayIndex][slotIndex] = slot; + } + return newAvailability; + }); + }; + + const handleSave = async () => { + try { + setSaving(true); + + // Convert availability object back to array format with day names + const availabilityArray: Array<{ + days: string[]; // Day names like "Monday", "Tuesday" + startTime: string; // Format: "09:00" + endTime: string; // Format: "10:00" + }> = []; + + Object.keys(availability).forEach((dayIndexStr) => { + const dayIndex = Number(dayIndexStr); + const slots = availability[dayIndex]; + if (slots && slots.length > 0) { + slots.forEach((slot) => { + // Convert day number to day name + const dayName = DAYS[dayIndex]; + + // Format time from "09:00:00" to "09:00" (remove seconds) + const formatTimeForAPI = (time: string): string => { + return time.substring(0, 5); // Take only HH:MM + }; + + availabilityArray.push({ + days: [dayName], + startTime: formatTimeForAPI(slot.startTime), + endTime: formatTimeForAPI(slot.endTime), + }); + }); + } + }); + + // Format overrides for API (ensure times are in HH:MM format) + const formattedOverrides = overrides.map((override) => ({ + date: override.date, + startTime: override.startTime.substring(0, 5), // Ensure HH:MM format + endTime: override.endTime.substring(0, 5), // Ensure HH:MM format + })); + + await CalComAPIService.updateSchedule(Number(id), { + name: scheduleName, + timeZone, + availability: availabilityArray, + isDefault: isDefault, + overrides: formattedOverrides, + }); + + Alert.alert("Success", "Schedule updated successfully", [ + { text: "OK", onPress: () => router.back() }, + ]); + } catch (error) { + showErrorAlert("Error", "Failed to update schedule. Please try again."); + } finally { + setSaving(false); + } + }; + + const handleSetAsDefault = async () => { + try { + await CalComAPIService.updateSchedule(Number(id), { + isDefault: true, + }); + setIsDefault(true); + Alert.alert("Success", "Schedule set as default successfully"); + } catch (error) { + showErrorAlert("Error", "Failed to set schedule as default. Please try again."); + } + }; + + const handleDelete = () => { + Alert.alert("Delete Schedule", `Are you sure you want to delete "${scheduleName}"?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + try { + await CalComAPIService.deleteSchedule(Number(id)); + Alert.alert("Success", "Schedule deleted successfully", [ + { text: "OK", onPress: () => router.back() }, + ]); + } catch (error) { + showErrorAlert("Error", "Failed to delete schedule. Please try again."); + } + }, + }, + ]); + }; + + const handleAddOverride = () => { + setEditingOverride(null); + setOverrideDate(""); + setOverrideStartTime("09:00"); + setOverrideEndTime("17:00"); + setIsUnavailable(false); + setShowOverrideModal(true); + }; + + const handleEditOverride = (index: number) => { + const override = overrides[index]; + setEditingOverride(index); + setOverrideDate(override.date); + setOverrideStartTime(override.startTime); + setOverrideEndTime(override.endTime); + setIsUnavailable(override.startTime === "00:00" && override.endTime === "00:00"); + setShowOverrideModal(true); + }; + + const handleDeleteOverride = (index: number) => { + Alert.alert("Delete Override", "Are you sure you want to delete this date override?", [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + setOverrides(overrides.filter((_, i) => i !== index)); + }, + }, + ]); + }; + + const handleSaveOverride = () => { + if (!overrideDate) { + Alert.alert("Error", "Please select a date"); + return; + } + + const newOverride = { + date: overrideDate, + startTime: isUnavailable ? "00:00" : overrideStartTime, + endTime: isUnavailable ? "00:00" : overrideEndTime, + }; + + if (editingOverride !== null) { + // Update existing override + const updated = [...overrides]; + updated[editingOverride] = newOverride; + setOverrides(updated); + } else { + // Add new override + setOverrides([...overrides, newOverride]); + } + + setShowOverrideModal(false); + }; + + const formatDateForDisplay = (dateStr: string): string => { + if (!dateStr) return ""; + const date = new Date(dateStr + "T00:00:00"); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + }; + + if (loading) { + return ( + + + Loading schedule... + + ); + } + + return ( + + {/* Glass Header */} + + + router.back()} + > + + + + + Edit Availability + + + + Save + + + + + + + {/* Schedule Name and Working Hours Display */} + + {scheduleName} + {Object.keys(availability).length > 0 ? ( + + {formatAvailabilityDisplay(availability).map((line, index) => ( + + {line} + + ))} + + ) : ( + No availability set + )} + + + {/* Availability Schedule */} + + Availability + {DAYS.map((day, dayIndex) => { + const daySlots = availability[dayIndex] || []; + const isEnabled = daySlots.length > 0; + const firstSlot = daySlots[0]; + + return ( + + + + toggleDay(dayIndex)} + trackColor={{ false: "#E5E5EA", true: "#34C759" }} + thumbColor="#fff" + style={{ transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }] }} + /> + + {DAY_ABBREVIATIONS[dayIndex]} + + {isEnabled && firstSlot ? ( + + + setShowTimePicker({ dayIndex, slotIndex: 0, type: "start" }) + } + className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1" + style={{ width: 85 }} + > + + {formatTime12Hour(firstSlot.startTime)} + + + - + + setShowTimePicker({ dayIndex, slotIndex: 0, type: "end" }) + } + className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1" + style={{ width: 85 }} + > + + {formatTime12Hour(firstSlot.endTime)} + + + + ) : null} + + {isEnabled ? ( + addTimeSlot(dayIndex)} className="p-1"> + + + ) : null} + + + {isEnabled && daySlots.length > 1 ? ( + + {daySlots.slice(1).map((slot, slotIndex) => ( + + + setShowTimePicker({ + dayIndex, + slotIndex: slotIndex + 1, + type: "start", + }) + } + className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1" + style={{ width: 85 }} + > + + {formatTime12Hour(slot.startTime)} + + + - + + setShowTimePicker({ + dayIndex, + slotIndex: slotIndex + 1, + type: "end", + }) + } + className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1" + style={{ width: 85 }} + > + + {formatTime12Hour(slot.endTime)} + + + removeTimeSlot(dayIndex, slotIndex + 1)} + className="p-1" + > + + + + ))} + + ) : null} + + ); + })} + + + {/* Timezone */} + + Timezone + setShowTimezoneModal(true)} + className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3" + > + {timeZone} + + + + + {/* Date Overrides */} + + Date overrides + + Add dates when your availability changes from your daily hours. + + + {overrides.length > 0 ? ( + + {overrides.map((override, index) => ( + + + + {formatDateForDisplay(override.date)} + + {override.startTime === "00:00" && override.endTime === "00:00" ? ( + Unavailable (All day) + ) : ( + + {formatTime12Hour(override.startTime + ":00")} -{" "} + {formatTime12Hour(override.endTime + ":00")} + + )} + + + handleEditOverride(index)} className="p-2"> + + + handleDeleteOverride(index)} className="p-2"> + + + + + ))} + + ) : null} + + + + Add an override + + + + + + {/* Bottom Action Bar */} + + + + Set as Default + + + + + + + + + + + + + + {/* Timezone Modal */} + setShowTimezoneModal(false)} + > + setShowTimezoneModal(false)} + > + e.stopPropagation()} + > + + Select Timezone + setShowTimezoneModal(false)}> + + + + + {timezones.map((tz) => ( + { + setTimeZone(tz); + setShowTimezoneModal(false); + }} + className="border-b border-[#E5E5EA] py-3" + > + {tz} + + ))} + + + + + + {/* Time Picker Modal */} + setShowTimePicker(null)} + > + setShowTimePicker(null)} + > + e.stopPropagation()} + > + + + Select {showTimePicker?.type === "start" ? "Start" : "End"} Time + + setShowTimePicker(null)}> + + + + + {TIME_OPTIONS.map((time) => { + const currentTime = + showTimePicker?.type === "start" + ? availability[showTimePicker.dayIndex]?.[ + showTimePicker.slotIndex + ]?.startTime.substring(0, 5) + : availability[showTimePicker?.dayIndex || 0]?.[ + showTimePicker?.slotIndex || 0 + ]?.endTime.substring(0, 5); + const isSelected = currentTime === time; + + return ( + { + if (showTimePicker) { + updateTimeSlot( + showTimePicker.dayIndex, + showTimePicker.slotIndex, + showTimePicker.type, + time + ); + setShowTimePicker(null); + } + }} + className={`border-b border-[#E5E5EA] py-3 ${isSelected ? "bg-[#E3F2FD]" : ""}`} + > + + {time} + + + ); + })} + + + + + + {/* Override Modal */} + setShowOverrideModal(false)} + > + setShowOverrideModal(false)} + > + e.stopPropagation()} + > + + + {editingOverride !== null ? "Edit Override" : "Add Override"} + + setShowOverrideModal(false)}> + + + + + + + {/* Date Picker */} + + + Select the dates to override + + + Enter date in YYYY-MM-DD format + + + {/* Unavailable Toggle */} + + + Mark unavailable (All day) + + + + + {/* Time Picker (only if not unavailable) */} + {!isUnavailable ? ( + + + Which hours are you free? + + + setShowOverrideTimePicker({ type: "start" })} + className="flex-1 rounded-lg border border-[#E5E5EA] bg-white px-3 py-3" + > + + {formatTime12Hour(overrideStartTime + ":00")} + + + - + setShowOverrideTimePicker({ type: "end" })} + className="flex-1 rounded-lg border border-[#E5E5EA] bg-white px-3 py-3" + > + + {formatTime12Hour(overrideEndTime + ":00")} + + + + + ) : null} + + {/* Save Button */} + + + Save Override + + + + + + + + + {/* Override Time Picker Modal */} + setShowOverrideTimePicker(null)} + > + setShowOverrideTimePicker(null)} + > + e.stopPropagation()} + > + + + Select {showOverrideTimePicker?.type === "start" ? "Start" : "End"} Time + + setShowOverrideTimePicker(null)}> + + + + + {TIME_OPTIONS.map((time) => { + const currentTime = + showOverrideTimePicker?.type === "start" ? overrideStartTime : overrideEndTime; + const isSelected = currentTime === time; + return ( + { + if (showOverrideTimePicker?.type === "start") { + setOverrideStartTime(time); + } else { + setOverrideEndTime(time); + } + setShowOverrideTimePicker(null); + }} + className={`border-b border-[#E5E5EA] py-3 ${isSelected ? "bg-[#F0F9FF]" : ""}`} + > + + {formatTime12Hour(time + ":00")} + + + ); + })} + + + + + + ); +} diff --git a/companion/components/screens/AvailabilityListScreen.tsx b/companion/components/screens/AvailabilityListScreen.tsx new file mode 100644 index 0000000000..4780f17557 --- /dev/null +++ b/companion/components/screens/AvailabilityListScreen.tsx @@ -0,0 +1,631 @@ +import { AppPressable } from "../../components/AppPressable"; +import { EmptyScreen } from "../../components/EmptyScreen"; +import { FullScreenModal } from "../../components/FullScreenModal"; +import { Header } from "../../components/Header"; +import { LoadingSpinner } from "../../components/LoadingSpinner"; +import { AvailabilityListItem } from "../../components/availability-list-item/AvailabilityListItem"; +import { + useSchedules, + useCreateSchedule, + useDeleteSchedule, + useDuplicateSchedule, + useSetScheduleAsDefault, +} from "../../hooks"; +import { CalComAPIService, Schedule } from "../../services/calcom"; +import { showErrorAlert } from "../../utils/alerts"; +import { offlineAwareRefresh } from "../../utils/network"; +import { shadows } from "../../utils/shadows"; +import { Ionicons } from "@expo/vector-icons"; +import { useRouter } from "expo-router"; +import React, { useState, useMemo, Activity } from "react"; +import { + View, + Text, + FlatList, + RefreshControl, + ActionSheetIOS, + Alert, + Platform, + TextInput, + ScrollView, +} from "react-native"; + +export interface AvailabilityListScreenProps { + searchQuery: string; + onSearchChange: (query: string) => void; + showCreateModal: boolean; + onShowCreateModalChange: (show: boolean) => void; +} + +export function AvailabilityListScreen({ + searchQuery, + onSearchChange, + showCreateModal, + onShowCreateModalChange, +}: AvailabilityListScreenProps) { + const router = useRouter(); + const [newScheduleName, setNewScheduleName] = useState(""); + const [showActionsModal, setShowActionsModal] = useState(false); + const [selectedSchedule, setSelectedSchedule] = useState(null); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + // Use React Query hooks + const { + data: schedules = [], + isLoading: loading, + isFetching, + error: queryError, + refetch, + } = useSchedules(); + + // Show refresh indicator when fetching + const refreshing = isFetching && !loading; + + const { mutate: createScheduleMutation, isPending: creating } = useCreateSchedule(); + const { mutate: deleteScheduleMutation, isPending: deleting } = useDeleteSchedule(); + const { mutate: duplicateScheduleMutation } = useDuplicateSchedule(); + const { mutate: setAsDefaultMutation } = useSetScheduleAsDefault(); + + // Convert query error to string + // Don't show error UI for authentication errors (user will be redirected to login) + // Only show error UI in development mode for other errors + const isAuthError = + queryError?.message?.includes("Authentication") || + queryError?.message?.includes("sign in") || + queryError?.message?.includes("401"); + const error = queryError && !isAuthError && __DEV__ ? "Failed to load availability." : null; + + // Filter schedules based on search query + const filteredSchedules = useMemo(() => { + if (searchQuery.trim() === "") { + return schedules; + } + const searchLower = searchQuery.toLowerCase(); + return schedules.filter((schedule) => schedule.name.toLowerCase().includes(searchLower)); + }, [schedules, searchQuery]); + + // Note: We don't use useFocusEffect here because schedules have Infinity stale time. + // Data only refreshes on mutations (create/update/delete) or manual pull-to-refresh. + + // Handle pull-to-refresh (offline-aware) + const onRefresh = () => offlineAwareRefresh(refetch); + + const handleSearch = (query: string) => { + onSearchChange(query); + }; + + const handleScheduleLongPress = (schedule: Schedule) => { + if (Platform.OS !== "ios") { + // Fallback for non-iOS platforms (Android Alert supports max 3 buttons) + const options = []; + if (!schedule.isDefault) { + options.push({ text: "Set as default", onPress: () => handleSetAsDefault(schedule) }); + } + options.push( + { text: "Duplicate", onPress: () => handleDuplicate(schedule) }, + { text: "Delete", style: "destructive" as const, onPress: () => handleDelete(schedule) } + ); + // Android Alert automatically adds cancel, so we don't need to include it explicitly + Alert.alert(schedule.name, "", options); + return; + } + + const options = ["Cancel"]; + if (!schedule.isDefault) { + options.push("Set as default"); + } + options.push("Duplicate", "Delete"); + + const destructiveButtonIndex = options.length - 1; // Delete button + const cancelButtonIndex = 0; + + ActionSheetIOS.showActionSheetWithOptions( + { + options, + destructiveButtonIndex, + cancelButtonIndex, + title: schedule.name, + }, + (buttonIndex) => { + if (buttonIndex === cancelButtonIndex) { + return; + } + + if (!schedule.isDefault) { + // Options: ["Cancel", "Set as default", "Duplicate", "Delete"] + if (buttonIndex === 1) { + handleSetAsDefault(schedule); + } else if (buttonIndex === 2) { + handleDuplicate(schedule); + } else if (buttonIndex === 3) { + handleDelete(schedule); + } + } else { + // Options: ["Cancel", "Duplicate", "Delete"] + if (buttonIndex === 1) { + handleDuplicate(schedule); + } else if (buttonIndex === 2) { + handleDelete(schedule); + } + } + } + ); + }; + + const handleSetAsDefault = (schedule: Schedule) => { + setAsDefaultMutation(schedule.id, { + onError: () => { + showErrorAlert("Error", "Failed to set schedule as default. Please try again."); + }, + }); + }; + + const handleDuplicate = (schedule: Schedule) => { + duplicateScheduleMutation(schedule.id, { + onError: () => { + showErrorAlert("Error", "Failed to duplicate schedule. Please try again."); + }, + }); + }; + + const handleDelete = (schedule: Schedule) => { + if (Platform.OS === "web") { + // Use custom modal for web + setShowDeleteModal(true); + } else { + // Use native Alert for iOS/Android + Alert.alert("Delete Schedule", `Are you sure you want to delete "${schedule.name}"?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + deleteScheduleMutation(schedule.id, { + onError: () => { + showErrorAlert("Error", "Failed to delete schedule. Please try again."); + }, + }); + }, + }, + ]); + } + }; + + const confirmDelete = () => { + if (!selectedSchedule) return; + + deleteScheduleMutation(selectedSchedule.id, { + onSuccess: () => { + setShowDeleteModal(false); + setSelectedSchedule(null); + }, + onError: () => { + showErrorAlert("Error", "Failed to delete schedule. Please try again."); + }, + }); + }; + + const handleSchedulePress = (schedule: Schedule) => { + router.push({ + pathname: "/availability-detail", + params: { id: schedule.id.toString() }, + }); + }; + + const handleCreateNew = () => { + setNewScheduleName(""); + onShowCreateModalChange(true); + }; + + const handleCreateSchedule = async () => { + if (!newScheduleName.trim()) { + Alert.alert("Error", "Please enter a schedule name"); + return; + } + + // Get user's timezone (default to America/New_York if not available) + let userTimezone = "America/New_York"; + try { + const userProfile = await CalComAPIService.getUserProfile(); + if (userProfile.timeZone) { + userTimezone = userProfile.timeZone; + } + } catch (error) { + console.log("Could not get user timezone, using default"); + } + + // Create schedule with Monday-Friday 9 AM - 5 PM default + createScheduleMutation( + { + name: newScheduleName.trim(), + timeZone: userTimezone, + isDefault: false, + availability: [ + { + days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], + startTime: "09:00", + endTime: "17:00", + }, + ], + }, + { + onSuccess: (newSchedule) => { + onShowCreateModalChange(false); + setNewScheduleName(""); + + // Navigate to edit the newly created schedule + router.push({ + pathname: "/availability-detail", + params: { + id: newSchedule.id.toString(), + }, + }); + }, + onError: (error) => { + const message = error instanceof Error ? error.message : String(error); + console.error("Failed to create schedule", message); + if (__DEV__) { + const stack = error instanceof Error ? error.stack : undefined; + console.debug("[AvailabilityListScreen] createSchedule failed", { message, stack }); + } + showErrorAlert("Error", "Failed to create schedule. Please try again."); + }, + } + ); + }; + + if (loading) { + return ( + +
+ + + + + ); + } + + if (error) { + return ( + +
+ + + + Unable to load availability + + {error} + refetch()}> + Retry + + + + ); + } + + // Determine what content to show + const showEmptyState = schedules.length === 0 && !loading; + const showSearchEmptyState = + filteredSchedules.length === 0 && searchQuery.trim() !== "" && !showEmptyState; + const showList = !showEmptyState && !showSearchEmptyState; + + return ( + <> + {/* Non-iOS header with search */} + +
+ + + + + New + + + + + {/* Empty state - no schedules */} + + } + contentInsetAdjustmentBehavior="automatic" + > + + + + + {/* Search bar and content for non-empty states */} + + {/* Search empty state */} + + } + > + + + + + {/* Schedules list */} + + item.id.toString()} + renderItem={({ item, index }) => ( + + )} + refreshControl={} + showsVerticalScrollIndicator={false} + contentInsetAdjustmentBehavior="automatic" + /> + + + + {/* Create Schedule Modal */} + onShowCreateModalChange(false)} + > + onShowCreateModalChange(false)} + > + e.stopPropagation()} + style={shadows.xl()} + > + {/* Header */} + + Add a new schedule + + + {/* Content */} + + + Name + + + + + {/* Footer */} + + + { + onShowCreateModalChange(false); + setNewScheduleName(""); + }} + disabled={creating} + > + Close + + + Continue + + + + + + + + {/* Schedule Actions Modal */} + setShowActionsModal(false)} + > + setShowActionsModal(false)} + > + e.stopPropagation()} + > + {/* Header */} + + + Schedule Actions + + + + {/* Actions List */} + + {/* Set as Default - only show if not already default */} + + <> + { + setShowActionsModal(false); + if (selectedSchedule) { + handleSetAsDefault(selectedSchedule); + } + }} + className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" + > + + Set as Default + + + + + + + {/* Duplicate */} + { + setShowActionsModal(false); + if (selectedSchedule) { + setTimeout(() => { + handleDuplicate(selectedSchedule); + }, 100); + } + }} + className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" + > + + Duplicate + + + {/* Separator */} + + + {/* Delete */} + { + setShowActionsModal(false); + if (selectedSchedule) { + // Small delay to ensure modal closes before alert shows + setTimeout(() => { + handleDelete(selectedSchedule); + }, 100); + } + }} + className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" + > + + Delete + + + + {/* Cancel button */} + + setShowActionsModal(false)} + > + Cancel + + + + + + + {/* Delete Confirmation Modal */} + !deleting && setShowDeleteModal(false)} + > + + + {/* Icon */} + + + + + + + {/* Title */} + + Delete Schedule + + + {/* Description */} + + Are you sure you want to delete "{selectedSchedule?.name}"? This action cannot be + undone. + + + {/* Buttons */} + + setShowDeleteModal(false)} + disabled={deleting} + > + Cancel + + + Delete + + + + + + + ); +} diff --git a/companion/components/screens/BookingDetailScreen.tsx b/companion/components/screens/BookingDetailScreen.tsx new file mode 100644 index 0000000000..40fbc43102 --- /dev/null +++ b/companion/components/screens/BookingDetailScreen.tsx @@ -0,0 +1,704 @@ +import { CalComAPIService, Booking } from "../../services/calcom"; +import { showErrorAlert } from "../../utils/alerts"; +import { openInAppBrowser } from "../../utils/browser"; +import { getDefaultLocationIconUrl, defaultLocations } from "../../utils/defaultLocations"; +import { formatAppIdToDisplayName } from "../../utils/formatters"; +import { getAppIconUrl } from "../../utils/getAppIconUrl"; +import { shadows } from "../../utils/shadows"; +import { AppPressable } from "../AppPressable"; +import { BookingActionsModal } from "../BookingActionsModal"; +import { FullScreenModal } from "../FullScreenModal"; +import { SvgImage } from "../SvgImage"; +import { Ionicons } from "@expo/vector-icons"; +import { GlassView } from "expo-glass-effect"; +import { useRouter } from "expo-router"; +import React, { useState, useEffect } from "react"; +import { View, Text, ScrollView, Alert, ActivityIndicator, TextInput } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +// Format date: "Tuesday, November 25, 2025" +const formatDateFull = (dateString: string): string => { + if (!dateString) return ""; + try { + const date = new Date(dateString); + return date.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + }); + } catch (error) { + return ""; + } +}; + +// Format time: "9:40pm - 10:00pm" +const formatTime12Hour = (dateString: string): string => { + if (!dateString) return ""; + try { + const date = new Date(dateString); + const hours = date.getHours(); + const minutes = date.getMinutes(); + const period = hours >= 12 ? "pm" : "am"; + const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours; + const minStr = minutes.toString().padStart(2, "0"); + return `${hour12}:${minStr}${period}`; + } catch (error) { + return ""; + } +}; + +// Get timezone from date string +const getTimezone = (dateString: string): string => { + if (!dateString) return ""; + try { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + return timeZone; + } catch (error) { + return ""; + } +}; + +// Get initials from a name (e.g., "Keith Williams" -> "KW", "Dhairyashil Shinde" -> "DS") +const getInitials = (name: string): string => { + if (!name) return ""; + const parts = name.trim().split(/\s+/); + if (parts.length === 0) return ""; + if (parts.length === 1) { + return parts[0].charAt(0).toUpperCase(); + } + // Get first letter of first name and first letter of last name + return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase(); +}; + +// Get location provider info +const getLocationProvider = (location: string | undefined, metadata?: Record) => { + // Check metadata for videoCallUrl first + const videoCallUrl = metadata?.videoCallUrl; + const locationToCheck = videoCallUrl || location; + + if (!locationToCheck) return null; + + // Check if it's a video call URL + if (typeof locationToCheck === "string" && locationToCheck.startsWith("http")) { + // Try to detect provider from URL + if (locationToCheck.includes("cal.com/video") || locationToCheck.includes("cal-video")) { + const iconUrl = getAppIconUrl("daily_video", "cal-video"); + return { + label: "Cal Video", + iconUrl: iconUrl || "https://app.cal.com/app-store/dailyvideo/icon.svg", + url: locationToCheck, + }; + } + // Check for other video providers by URL pattern + const videoProviders = [ + { pattern: /zoom\.us/, label: "Zoom", type: "zoom_video", appId: "zoom" }, + { + pattern: /meet\.google\.com/, + label: "Google Meet", + type: "google_video", + appId: "google-meet", + }, + { + pattern: /teams\.microsoft\.com/, + label: "Microsoft Teams", + type: "office365_video", + appId: "msteams", + }, + ]; + + for (const provider of videoProviders) { + if (provider.pattern.test(locationToCheck)) { + const iconUrl = getAppIconUrl(provider.type, provider.appId); + return { + label: provider.label, + iconUrl: iconUrl, + url: locationToCheck, + }; + } + } + + // Generic link meeting + const linkIconUrl = getDefaultLocationIconUrl("link") || "https://app.cal.com/link.svg"; + return { + label: "Link Meeting", + iconUrl: linkIconUrl, + url: locationToCheck, + }; + } + + // Check if it's an integration location (e.g., "integrations:zoom", "integrations:cal-video") + if (typeof locationToCheck === "string" && locationToCheck.startsWith("integrations:")) { + const appId = locationToCheck.replace("integrations:", ""); + const iconUrl = getAppIconUrl("", appId); + + if (iconUrl) { + return { + label: formatAppIdToDisplayName(appId), + iconUrl: iconUrl, + url: null, + }; + } + } + + // Check if it's a default location type + const defaultLocation = defaultLocations.find((loc) => loc.type === locationToCheck); + if (defaultLocation) { + return { + label: defaultLocation.label, + iconUrl: defaultLocation.iconUrl, + url: null, + }; + } + + // Fallback: return as plain text location + return { + label: locationToCheck as string, + iconUrl: null, + url: null, + }; +}; + +export interface BookingDetailScreenProps { + uid: string; +} + +export function BookingDetailScreen({ uid }: BookingDetailScreenProps) { + const router = useRouter(); + const insets = useSafeAreaInsets(); + + const [loading, setLoading] = useState(true); + const [booking, setBooking] = useState(null); + const [error, setError] = useState(null); + const [showActionsModal, setShowActionsModal] = useState(false); + const [showRescheduleModal, setShowRescheduleModal] = useState(false); + const [rescheduleDate, setRescheduleDate] = useState(""); + const [rescheduleTime, setRescheduleTime] = useState(""); + const [rescheduleReason, setRescheduleReason] = useState(""); + const [rescheduling, setRescheduling] = useState(false); + + useEffect(() => { + if (uid) { + fetchBooking(); + } + }, [uid]); + + const fetchBooking = async () => { + try { + setLoading(true); + setError(null); + const bookingData = await CalComAPIService.getBookingByUid(uid); + // Avoid logging booking payloads: they may contain PII (names/emails/notes). + if (__DEV__) { + console.debug("[BookingDetailScreen] booking fetched", { + uid: bookingData.uid, + status: bookingData.status, + hostCount: bookingData.hosts?.length ?? (bookingData.user ? 1 : 0), + attendeeCount: bookingData.attendees?.length ?? 0, + hasRecurringEventId: Boolean(bookingData.recurringEventId), + }); + } + setBooking(bookingData); + } catch (err) { + console.error("Error fetching booking"); + if (__DEV__) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + console.debug("[BookingDetailScreen] fetchBooking failed", { message, stack }); + } + setError("Failed to load booking. Please try again."); + if (__DEV__) { + Alert.alert("Error", "Failed to load booking. Please try again.", [ + { text: "OK", onPress: () => router.back() }, + ]); + } else { + router.back(); + } + } finally { + setLoading(false); + } + }; + + const handleJoinMeeting = () => { + if (!booking?.location) return; + + const provider = getLocationProvider(booking.location); + if (provider?.url) { + openInAppBrowser(provider.url, "meeting link"); + } + }; + + const openRescheduleModal = () => { + if (!booking) return; + + // Pre-fill with the current booking date/time (using local timezone consistently) + const currentDate = new Date(booking.startTime); + const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart( + 2, + "0" + )}-${String(currentDate.getDate()).padStart(2, "0")}`; + const timeStr = `${String(currentDate.getHours()).padStart(2, "0")}:${String( + currentDate.getMinutes() + ).padStart(2, "0")}`; + + setRescheduleDate(dateStr); + setRescheduleTime(timeStr); + setRescheduleReason(""); + setShowRescheduleModal(true); + }; + + const handleReschedule = async () => { + if (!booking || !rescheduleDate || !rescheduleTime) { + showErrorAlert("Error", "Please enter both date and time"); + return; + } + + // Parse the date and time + const dateTimeStr = `${rescheduleDate}T${rescheduleTime}:00`; + const newDateTime = new Date(dateTimeStr); + + // Validate the date + if (isNaN(newDateTime.getTime())) { + showErrorAlert( + "Error", + "Invalid date or time format. Please use YYYY-MM-DD for date and HH:MM for time." + ); + return; + } + + // Check if the new time is in the future + if (newDateTime <= new Date()) { + showErrorAlert("Error", "Please select a future date and time"); + return; + } + + // Convert to UTC ISO string + const startUtc = newDateTime.toISOString(); + + setRescheduling(true); + try { + await CalComAPIService.rescheduleBooking(booking.uid, { + start: startUtc, + reschedulingReason: rescheduleReason.trim() || undefined, + }); + + Alert.alert("Success", "Booking rescheduled successfully"); + setShowRescheduleModal(false); + setRescheduleDate(""); + setRescheduleTime(""); + setRescheduleReason(""); + + // Refresh booking data + await fetchBooking(); + } catch (error) { + console.error("Failed to reschedule booking"); + if (__DEV__) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + console.debug("[BookingDetailScreen] rescheduleBooking failed", { message, stack }); + } + showErrorAlert("Error", "Failed to reschedule booking. Please try again."); + } finally { + setRescheduling(false); + } + }; + + if (loading) { + return ( + + + Loading booking... + + ); + } + + if (error || !booking) { + return ( + + + + {error || "Booking not found"} + + router.back()}> + Go Back + + + ); + } + + const startTime = booking.start || booking.startTime || ""; + const endTime = booking.end || booking.endTime || ""; + const dateFormatted = formatDateFull(startTime); + const timeFormatted = `${formatTime12Hour(startTime)} - ${formatTime12Hour(endTime)}`; + const timezone = getTimezone(startTime); + const locationProvider = getLocationProvider(booking.location, booking.responses); + + return ( + + {/* Glass Header */} + + + router.back()} + > + + + + + {booking?.title || "Booking Details"} + + + setShowActionsModal(true)} + > + + + + + + + {/* Title */} + + {booking.title} + + {dateFormatted} {timeFormatted} ({timezone}) + + + + {/* Who Section */} + + Who + {/* Show host from user field or hosts array */} + {booking.user || (booking.hosts && booking.hosts.length > 0) ? ( + + {booking.user ? ( + + + + {getInitials(booking.user.name)} + + + + + {booking.user.name} + + host + + + {booking.user.email} + + + ) : booking.hosts && booking.hosts.length > 0 ? ( + booking.hosts.map((host, hostIndex) => ( + 0 ? "mt-4" : ""}`} + > + + + {getInitials(host.name || "Host")} + + + + + + {host.name || "Host"} + + + host + + + {host.email && {host.email}} + + + )) + ) : null} + + ) : null} + {booking.attendees && booking.attendees.length > 0 ? ( + + {booking.attendees.map((attendee, index) => ( + 0 ? "mt-4" : ""}`}> + + + {getInitials(attendee.name)} + + + + {attendee.name} + {attendee.email} + + + ))} + + ) : null} + + + {/* Where Section */} + {locationProvider ? ( + + Where + {locationProvider.url ? ( + + {locationProvider.iconUrl ? ( + + ) : null} + {locationProvider.label}: + + {locationProvider.url} + + + ) : ( + + {locationProvider.iconUrl ? ( + + ) : null} + {locationProvider.label} + + )} + + ) : null} + + {/* Recurring Event Section */} + {booking.recurringEventId || + (booking as { recurringBookingUid?: string }).recurringBookingUid ? ( + + Recurring Event + Every 2 weeks for 6 occurrences + + ) : null} + + {/* Description Section */} + {booking.description ? ( + + Description + {booking.description} + + ) : null} + + {/* Join Meeting Button */} + {locationProvider?.url ? ( + + {locationProvider.iconUrl ? ( + + ) : null} + + Join {locationProvider.label} + + + ) : null} + + + {/* Booking Actions Modal */} + setShowActionsModal(false)} + booking={booking} + hasLocationUrl={!!locationProvider?.url} + isUpcoming={booking ? new Date(booking.startTime) > new Date() : false} + isPast={booking ? new Date(booking.startTime) <= new Date() : false} + isCancelled={booking?.status?.toUpperCase() === "CANCELLED"} + isUnconfirmed={booking?.status?.toUpperCase() === "PENDING"} + onReschedule={openRescheduleModal} + onEditLocation={() => { + Alert.alert("Edit Location", "Edit location functionality coming soon"); + }} + onAddGuests={() => { + Alert.alert("Add Guests", "Add guests functionality coming soon"); + }} + onViewRecordings={() => { + Alert.alert("View Recordings", "View recordings functionality coming soon"); + }} + onMeetingSessionDetails={() => { + Alert.alert( + "Meeting Session Details", + "Meeting session details functionality coming soon" + ); + }} + onMarkNoShow={() => { + Alert.alert("Mark as No-Show", "Mark as no-show functionality coming soon"); + }} + onReportBooking={() => { + Alert.alert("Report Booking", "Report booking functionality coming soon"); + }} + onCancelBooking={() => { + Alert.alert("Cancel Booking", "Are you sure you want to cancel this booking?", [ + { text: "No", style: "cancel" }, + { + text: "Yes, Cancel", + style: "destructive", + onPress: () => { + // TODO: Implement cancel booking + if (__DEV__) + console.debug("[BookingDetailScreen] cancel booking (not implemented yet)"); + }, + }, + ]); + }} + /> + + {/* Reschedule Modal */} + setShowRescheduleModal(false)} + > + setShowRescheduleModal(false)} + > + e.stopPropagation()} + style={shadows.xl()} + > + {/* Header */} + + + + + + + + Reschedule Booking + + + Select a new date and time for this booking. + + + + + + {/* Content */} + + {/* Date Input */} + + + New Date + (YYYY-MM-DD) + + + + + {/* Time Input */} + + + New Time + (HH:MM, 24-hour) + + + + + {/* Reason Input */} + + + Reason + (Optional) + + + + + + {/* Footer */} + + + { + setShowRescheduleModal(false); + setRescheduleDate(""); + setRescheduleTime(""); + setRescheduleReason(""); + }} + disabled={rescheduling} + > + Cancel + + + {rescheduling ? ( + + ) : ( + Reschedule + )} + + + + + + + + ); +} diff --git a/companion/contexts/AuthContext.tsx b/companion/contexts/AuthContext.tsx index 0bfd077a9e..e6e273bd41 100644 --- a/companion/contexts/AuthContext.tsx +++ b/companion/contexts/AuthContext.tsx @@ -36,6 +36,10 @@ interface AuthProviderProps { // Use the shared secure storage adapter const storage = secureStorage; +const getErrorMessage = (error: unknown) => + error instanceof Error ? error.message : String(error); +const getErrorStack = (error: unknown) => (error instanceof Error ? error.stack : undefined); + export function AuthProvider({ children }: AuthProviderProps) { const [isAuthenticated, setIsAuthenticated] = useState(false); const [accessToken, setAccessToken] = useState(null); @@ -143,7 +147,14 @@ export function AuthProvider({ children }: AuthProviderProps) { newRefreshToken || refreshToken || undefined ); } catch (error) { - console.error("Failed to handle token refresh:", error); + const message = getErrorMessage(error); + console.error("Failed to handle token refresh", message); + if (__DEV__) { + console.debug("[AuthContext] token refresh handler failed", { + message, + stack: getErrorStack(error), + }); + } await logout(); } }; @@ -213,7 +224,14 @@ export function AuthProvider({ children }: AuthProviderProps) { handleWebSessionAuth(); } } catch (error) { - console.error("Failed to check auth state:", error); + const message = getErrorMessage(error); + console.error("Failed to check auth state", message); + if (__DEV__) { + console.debug("[AuthContext] checkAuthState failed", { + message, + stack: getErrorStack(error), + }); + } } finally { setLoading(false); } @@ -234,7 +252,14 @@ export function AuthProvider({ children }: AuthProviderProps) { await setupAfterLogin(tokens.accessToken, tokens.refreshToken); } } catch (error) { - console.error("Failed to login from web session:", error); + const message = getErrorMessage(error); + console.error("Failed to login from web session", message); + if (__DEV__) { + console.debug("[AuthContext] loginFromWebSession failed", { + message, + stack: getErrorStack(error), + }); + } throw error; } }; @@ -267,7 +292,14 @@ export function AuthProvider({ children }: AuthProviderProps) { // Clear PKCE parameters oauthService.clearPKCEParams(); } catch (error) { - console.error("OAuth login failed:", error); + const message = getErrorMessage(error); + console.error("OAuth login failed", message); + if (__DEV__) { + console.debug("[AuthContext] loginWithOAuth failed", { + message, + stack: getErrorStack(error), + }); + } throw error; } finally { setLoading(false); @@ -279,7 +311,11 @@ export function AuthProvider({ children }: AuthProviderProps) { await clearAuth(); resetAuthState(); } catch (error) { - console.error("Failed to logout:", error); + const message = getErrorMessage(error); + console.error("Failed to logout", message); + if (__DEV__) { + console.debug("[AuthContext] logout failed", { message, stack: getErrorStack(error) }); + } } }; diff --git a/companion/extension/entrypoints/content.ts b/companion/extension/entrypoints/content.ts index 36c825dcb3..69951a2d80 100644 --- a/companion/extension/entrypoints/content.ts +++ b/companion/extension/entrypoints/content.ts @@ -17,7 +17,7 @@ export default defineContentScript({ console.log("Cal.com: Gmail integration initialized successfully"); } catch (error) { // Fail silently - don't break Gmail UI - console.error("Cal.com: Failed to initialize Gmail integration:", error); + console.error("Cal.com: Failed to initialize Gmail integration"); } } @@ -29,7 +29,7 @@ export default defineContentScript({ console.log("Cal.com: Google Calendar integration initialized successfully"); } catch (error) { // Fail silently - don't break Google Calendar UI - console.error("Cal.com: Failed to initialize Google Calendar integration:", error); + console.error("Cal.com: Failed to initialize Google Calendar integration"); } } @@ -1536,8 +1536,8 @@ export default defineContentScript({ ${datesHTML} @@ -1642,7 +1642,7 @@ export default defineContentScript({ return false; } } catch (error) { - console.error("Cal.com: Critical error inserting HTML:", error); + console.error("Cal.com: Critical error inserting HTML"); return false; } } @@ -1871,7 +1871,7 @@ export default defineContentScript({ }, 1000); } catch (error) { // Critical failure - log but don't break Gmail - console.error("Cal.com: Failed to initialize chip watcher:", error); + console.error("Cal.com: Failed to initialize chip watcher"); } } @@ -2203,7 +2203,7 @@ export default defineContentScript({ embedButton.style.opacity = "1"; embedButton.style.cursor = "pointer"; } catch (error) { - console.error("Cal.com: Failed to insert embed:", error); + console.error("Cal.com: Failed to insert embed"); // Check if this is the "Extension context invalidated" error const isContextInvalidated = @@ -2371,7 +2371,7 @@ export default defineContentScript({ window.removeEventListener("resize", updatePosition); }; } catch (error) { - console.error("Cal.com: Failed to create or insert action bar:", error); + console.error("Cal.com: Failed to create or insert action bar"); // Clean up if something failed try { actionBar.remove(); @@ -2381,7 +2381,7 @@ export default defineContentScript({ } } catch (error) { // Catch-all to prevent breaking Gmail UI - console.error("Cal.com: Error handling Google chip:", error); + console.error("Cal.com: Error handling Google chip"); return; } } @@ -2458,8 +2458,8 @@ export default defineContentScript({
${ parsedData.slots.length } time slot${parsedData.slots.length > 1 ? "s" : ""} • ${ - parsedData.detectedDuration - }min each
+ parsedData.detectedDuration + }min each `; @@ -2912,7 +2912,7 @@ export default defineContentScript({ }); }); } catch (error) { - console.error("Error showing Cal.com suggestion menu:", error); + console.error("Error showing Cal.com suggestion menu"); loadingDiv.remove(); const errorMessage = error instanceof Error ? error.message : ""; diff --git a/companion/extension/lib/google-calendar.ts b/companion/extension/lib/google-calendar.ts index 9e0c563f44..9e56ed5e73 100644 --- a/companion/extension/lib/google-calendar.ts +++ b/companion/extension/lib/google-calendar.ts @@ -543,7 +543,7 @@ function parseAttendees(eventPopup: Element): AttendeeInfo[] { const whoSection = allText.match(/Who:([\s\S]*?)(?:Where:|When:|$)/i); if (whoSection) { const whoText = whoSection[1]; - const whoEmails = whoText.match(emailRegex) || []; + const whoEmails: string[] = whoText.match(emailRegex) ?? []; whoEmails.forEach((email) => { const emailLower = email.toLowerCase(); @@ -584,7 +584,7 @@ function parseAttendees(eventPopup: Element): AttendeeInfo[] { guestListContainers.forEach((container) => { const containerText = container.textContent || ""; - const emails = containerText.match(emailRegex) || []; + const emails: string[] = containerText.match(emailRegex) ?? []; emails.forEach((email) => { const emailLower = email.toLowerCase(); diff --git a/companion/hooks/useBookingActions.ts b/companion/hooks/useBookingActions.ts index 4ec9a61534..703f28d726 100644 --- a/companion/hooks/useBookingActions.ts +++ b/companion/hooks/useBookingActions.ts @@ -176,7 +176,7 @@ export const useBookingActions = ({ Alert.alert("Success", "Event cancelled successfully"); }, onError: (error) => { - console.error("Failed to cancel booking:", error); + console.error("Failed to cancel booking"); showErrorAlert("Error", "Failed to cancel event. Please try again."); }, } diff --git a/companion/hooks/useBookings.ts b/companion/hooks/useBookings.ts index 2221b86b03..c074187f06 100644 --- a/companion/hooks/useBookings.ts +++ b/companion/hooks/useBookings.ts @@ -108,7 +108,7 @@ export function useCancelBooking() { }); }, onError: (error) => { - console.error("Failed to cancel booking:", error); + console.error("Failed to cancel booking"); }, }); } @@ -152,7 +152,7 @@ export function useRescheduleBooking() { }); }, onError: (error) => { - console.error("Failed to reschedule booking:", error); + console.error("Failed to reschedule booking"); }, }); } @@ -184,7 +184,7 @@ export function useConfirmBooking() { }); }, onError: (error) => { - console.error("Failed to confirm booking:", error); + console.error("Failed to confirm booking"); }, }); } @@ -217,7 +217,7 @@ export function useDeclineBooking() { }); }, onError: (error) => { - console.error("Failed to decline booking:", error); + console.error("Failed to decline booking"); }, }); } diff --git a/companion/hooks/useEventTypes.ts b/companion/hooks/useEventTypes.ts index edcf9fe652..08f2fee9e0 100644 --- a/companion/hooks/useEventTypes.ts +++ b/companion/hooks/useEventTypes.ts @@ -93,7 +93,12 @@ export function useCreateEventType() { queryClient.setQueryData(queryKeys.eventTypes.detail(newEventType.id), newEventType); }, onError: (error) => { - console.error("Failed to create event type:", error); + console.error("Failed to create event type"); + if (__DEV__) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + console.debug("[useCreateEventType] failed", { message, stack }); + } }, }); } @@ -127,7 +132,12 @@ export function useUpdateEventType() { queryClient.setQueryData(queryKeys.eventTypes.detail(variables.id), updatedEventType); }, onError: (error) => { - console.error("Failed to update event type:", error); + console.error("Failed to update event type"); + if (__DEV__) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + console.debug("[useUpdateEventType] failed", { message, stack }); + } }, }); } @@ -173,7 +183,12 @@ export function useDeleteEventType() { if (context?.previousEventTypes) { queryClient.setQueryData(queryKeys.eventTypes.lists(), context.previousEventTypes); } - console.error("Failed to delete event type:", error); + console.error("Failed to delete event type"); + if (__DEV__) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + console.debug("[useDeleteEventType] failed", { message, stack }); + } }, onSettled: () => { // Always refetch after error or success @@ -233,7 +248,12 @@ export function useDuplicateEventType() { queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.lists() }); }, onError: (error) => { - console.error("Failed to duplicate event type:", error); + console.error("Failed to duplicate event type"); + if (__DEV__) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + console.debug("[useDuplicateEventType] failed", { message, stack }); + } }, }); } diff --git a/companion/hooks/useSchedules.ts b/companion/hooks/useSchedules.ts index f2746bbb96..f73a401cf9 100644 --- a/companion/hooks/useSchedules.ts +++ b/companion/hooks/useSchedules.ts @@ -147,7 +147,7 @@ export function useCreateSchedule() { queryClient.setQueryData(queryKeys.schedules.detail(newSchedule.id), newSchedule); }, onError: (error) => { - console.error("Failed to create schedule:", error); + console.error("Failed to create schedule"); }, }); } @@ -181,7 +181,7 @@ export function useUpdateSchedule() { queryClient.setQueryData(queryKeys.schedules.detail(variables.id), updatedSchedule); }, onError: (error) => { - console.error("Failed to update schedule:", error); + console.error("Failed to update schedule"); }, }); } @@ -208,7 +208,7 @@ export function useSetScheduleAsDefault() { queryClient.invalidateQueries({ queryKey: queryKeys.schedules.all }); }, onError: (error) => { - console.error("Failed to set schedule as default:", error); + console.error("Failed to set schedule as default"); }, }); } @@ -252,7 +252,7 @@ export function useDeleteSchedule() { if (context?.previousSchedules) { queryClient.setQueryData(queryKeys.schedules.lists(), context.previousSchedules); } - console.error("Failed to delete schedule:", error); + console.error("Failed to delete schedule"); }, onSettled: () => { // Always refetch after error or success @@ -283,7 +283,7 @@ export function useDuplicateSchedule() { queryClient.invalidateQueries({ queryKey: queryKeys.schedules.lists() }); }, onError: (error) => { - console.error("Failed to duplicate schedule:", error); + console.error("Failed to duplicate schedule"); }, }); } diff --git a/companion/hooks/useUserProfile.ts b/companion/hooks/useUserProfile.ts index 944aa9c885..3934edf751 100644 --- a/companion/hooks/useUserProfile.ts +++ b/companion/hooks/useUserProfile.ts @@ -114,7 +114,12 @@ export function useUpdateUserProfile() { if (context?.previousProfile) { queryClient.setQueryData(queryKeys.userProfile.current(), context.previousProfile); } - console.error("Failed to update user profile:", error); + console.error("Failed to update user profile"); + if (__DEV__) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + console.debug("[useUpdateUserProfile] failed", { message, stack }); + } }, onSettled: () => { // Always refetch after error or success diff --git a/companion/package.json b/companion/package.json index ae717510f4..6ca6cadeee 100644 --- a/companion/package.json +++ b/companion/package.json @@ -1,13 +1,14 @@ { "name": "cal-companion", "displayName": "Cal.com Companion", - "version": "1.7.4", + "version": "1.7.5", "main": "index.js", "scripts": { "start": "expo start", "android": "expo run:android", "ios": "expo run:ios", "web": "expo start --web", + "typecheck": "tsc -p tsconfig.json --noEmit", "ext": "wxt", "ext:build": "wxt build", "ext:build-prod": "BUILD_FOR_STORE=true wxt build", diff --git a/companion/services/calcom.ts b/companion/services/calcom.ts index a0760e3522..cf44ab1f0f 100644 --- a/companion/services/calcom.ts +++ b/companion/services/calcom.ts @@ -191,7 +191,7 @@ export class CalComAPIService { throw new Error("Invalid response from update user profile API"); } catch (error) { - console.error("updateUserProfile error:", error); + console.error("updateUserProfile error"); throw error; } } @@ -321,7 +321,7 @@ export class CalComAPIService { "2024-06-14" ); } catch (error) { - console.error("Delete API error:", error); + console.error("Delete API error"); throw error; } } @@ -349,7 +349,7 @@ export class CalComAPIService { throw new Error("Invalid response from create event type API"); } catch (error) { - console.error("createEventType error:", error); + console.error("createEventType error"); throw error; } } @@ -406,7 +406,7 @@ export class CalComAPIService { throw new Error("Invalid response from reschedule booking API"); } catch (error) { - console.error("rescheduleBooking error:", error); + console.error("rescheduleBooking error"); throw error; } } @@ -431,7 +431,7 @@ export class CalComAPIService { throw new Error("Invalid response from confirm booking API"); } catch (error) { - console.error("confirmBooking error:", error); + console.error("confirmBooking error"); throw error; } } @@ -462,7 +462,7 @@ export class CalComAPIService { throw new Error("Invalid response from decline booking API"); } catch (error) { - console.error("declineBooking error:", error); + console.error("declineBooking error"); throw error; } } @@ -502,7 +502,7 @@ export class CalComAPIService { throw new Error("Invalid response from mark absent API"); } catch (error) { - console.error("markAbsent error:", error); + console.error("markAbsent error"); throw error; } } @@ -589,7 +589,7 @@ export class CalComAPIService { } throw new Error("Invalid response from get booking API"); } catch (error) { - console.error("getBookingByUid error:", error); + console.error("getBookingByUid error"); throw error; } } @@ -775,7 +775,7 @@ export class CalComAPIService { throw new Error("Invalid response from create schedule API"); } catch (error) { - console.error("createSchedule error:", error); + console.error("createSchedule error"); throw error; } } @@ -802,7 +802,7 @@ export class CalComAPIService { return null; } catch (error) { - console.error("getScheduleById error:", error); + console.error("getScheduleById error"); throw error; } } @@ -820,7 +820,7 @@ export class CalComAPIService { return []; } catch (error) { - console.error("getConferencingOptions error:", error); + console.error("getConferencingOptions error"); throw error; } } @@ -849,7 +849,7 @@ export class CalComAPIService { return null; } } - console.error("getEventTypeById error:", error); + console.error("getEventTypeById error"); throw error; } } @@ -946,7 +946,7 @@ export class CalComAPIService { throw new Error("Invalid response from update event type API"); } catch (error) { - console.error("updateEventType error:", error); + console.error("updateEventType error"); throw error; } } @@ -992,7 +992,7 @@ export class CalComAPIService { throw new Error("Invalid response from update schedule API"); } catch (error) { - console.error("updateSchedule error:", error); + console.error("updateSchedule error"); throw error; } } @@ -1017,7 +1017,7 @@ export class CalComAPIService { throw new Error("Invalid response from duplicate schedule API"); } catch (error) { - console.error("duplicateSchedule error:", error); + console.error("duplicateSchedule error"); throw error; } } @@ -1036,7 +1036,7 @@ export class CalComAPIService { "2024-06-11" ); } catch (error) { - console.error("deleteSchedule error:", error); + console.error("deleteSchedule error"); throw error; } } @@ -1056,7 +1056,7 @@ export class CalComAPIService { return []; } catch (error) { - console.error("getWebhooks error:", error); + console.error("getWebhooks error"); throw error; } } @@ -1079,7 +1079,7 @@ export class CalComAPIService { throw new Error("Invalid response from create webhook API"); } catch (error) { - console.error("createWebhook error:", error); + console.error("createWebhook error"); throw error; } } @@ -1105,7 +1105,7 @@ export class CalComAPIService { throw new Error("Invalid response from update webhook API"); } catch (error) { - console.error("updateWebhook error:", error); + console.error("updateWebhook error"); throw error; } } @@ -1117,7 +1117,7 @@ export class CalComAPIService { method: "DELETE", }); } catch (error) { - console.error("deleteWebhook error:", error); + console.error("deleteWebhook error"); throw error; } } @@ -1135,7 +1135,7 @@ export class CalComAPIService { return []; } catch (error) { - console.error("getEventTypeWebhooks error:", error); + console.error("getEventTypeWebhooks error"); throw error; } } @@ -1164,7 +1164,7 @@ export class CalComAPIService { throw new Error("Invalid response from create event type webhook API"); } catch (error) { - console.error("createEventTypeWebhook error:", error); + console.error("createEventTypeWebhook error"); throw error; } } @@ -1194,7 +1194,7 @@ export class CalComAPIService { throw new Error("Invalid response from update event type webhook API"); } catch (error) { - console.error("updateEventTypeWebhook error:", error); + console.error("updateEventTypeWebhook error"); throw error; } } @@ -1206,7 +1206,7 @@ export class CalComAPIService { method: "DELETE", }); } catch (error) { - console.error("deleteEventTypeWebhook error:", error); + console.error("deleteEventTypeWebhook error"); throw error; } } @@ -1228,7 +1228,7 @@ export class CalComAPIService { return []; } catch (error) { - console.error("getEventTypePrivateLinks error:", error); + console.error("getEventTypePrivateLinks error"); throw error; } } @@ -1257,7 +1257,7 @@ export class CalComAPIService { throw new Error("Invalid response from create private link API"); } catch (error) { - console.error("createEventTypePrivateLink error:", error); + console.error("createEventTypePrivateLink error"); throw error; } } @@ -1287,7 +1287,7 @@ export class CalComAPIService { throw new Error("Invalid response from update private link API"); } catch (error) { - console.error("updateEventTypePrivateLink error:", error); + console.error("updateEventTypePrivateLink error"); throw error; } } @@ -1299,7 +1299,7 @@ export class CalComAPIService { method: "DELETE", }); } catch (error) { - console.error("deleteEventTypePrivateLink error:", error); + console.error("deleteEventTypePrivateLink error"); throw error; } } diff --git a/companion/utils/browser.ts b/companion/utils/browser.ts index 33d0981783..eecfddff97 100644 --- a/companion/utils/browser.ts +++ b/companion/utils/browser.ts @@ -57,7 +57,12 @@ export const openInAppBrowser = async ( await WebBrowser.openBrowserAsync(url, browserOptions); } catch (error) { - console.error(`Failed to open ${url}:`, error); + console.error("Failed to open link"); + if (__DEV__) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + console.debug("[openInAppBrowser] failed", { message, stack, fallbackMessage }); + } showErrorAlert("Error", `Failed to open ${fallbackMessage || "link"}. Please try again.`); } }; diff --git a/companion/utils/gmailGoogleChipParser.ts b/companion/utils/gmailGoogleChipParser.ts index 1989103b80..7d550cbfce 100644 --- a/companion/utils/gmailGoogleChipParser.ts +++ b/companion/utils/gmailGoogleChipParser.ts @@ -149,7 +149,12 @@ export function parseGoogleChip(chipElement: HTMLElement): ParsedGoogleChip | nu detectedDuration, }; } catch (error) { - console.error("Error parsing Google chip:", error); + console.error("Error parsing Google chip"); + if (__DEV__) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + console.debug("[gmailGoogleChipParser] parse failed", { message, stack }); + } return null; } } diff --git a/companion/utils/index.ts b/companion/utils/index.ts index 2376dad4fb..224349c17d 100644 --- a/companion/utils/index.ts +++ b/companion/utils/index.ts @@ -31,7 +31,7 @@ export { showErrorAlert } from "./alerts"; export { openInAppBrowser } from "./browser"; // Network utilities -export { isOnline, subscribeToNetworkChanges } from "./network"; +export { isOnline } from "./network"; // Slug utilities export { slugify } from "./slugify"; diff --git a/companion/wxt.config.ts b/companion/wxt.config.ts index 05aaffdb9b..3459780282 100644 --- a/companion/wxt.config.ts +++ b/companion/wxt.config.ts @@ -58,7 +58,7 @@ export default defineConfig({ outDir: ".output", manifest: { name: "Cal.com Companion", - version: "1.7.4", + version: "1.7.5", description: "Your calendar companion for quick booking and scheduling", permissions: ["activeTab", "storage", "identity"], host_permissions: [ @@ -68,7 +68,7 @@ export default defineConfig({ "https://mail.google.com/*", "https://calendar.google.com/*", // Include localhost permission for dev builds (needed for iframe to load) - ...(!isBuildForStore ? ["http://localhost:*/*"] : []), + // ...(!isBuildForStore ? ["http://localhost:*/*"] : []), ], content_security_policy: { extension_pages: !isBuildForStore