From 730c9599cee792f247e4f1e8a1e916650a251ee3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:56:49 +0000 Subject: [PATCH] feat: add native Out of Office (OOO) feature Port OOO feature from cal.com PR #27642 to companion app. New files: - OOO route layout and screens (index, index.ios, create-entry) - OOO components (CreateOutOfOfficeModal, OutOfOfficeListItem, OutOfOfficeListSkeleton) - OutOfOfficeScreen for web - useOutOfOffice hooks (CRUD operations with React Query) - OOO service layer for API calls - OOO type definitions Modified files: - More menu: added OOO entry with airplane icon - Profile sheet: changed OOO from external link to native navigation - Tab layout: added hidden (ooo) tab screen - Barrel exports: hooks, services, types Co-Authored-By: peer@cal.com --- app/(tabs)/(more)/index.ios.tsx | 10 +- app/(tabs)/(more)/index.tsx | 15 +- app/(tabs)/(ooo)/_layout.tsx | 10 + app/(tabs)/(ooo)/create-entry.tsx | 399 ++++++++++++++++++ app/(tabs)/(ooo)/index.ios.tsx | 313 ++++++++++++++ app/(tabs)/(ooo)/index.tsx | 5 + app/(tabs)/_layout.tsx | 14 +- app/profile-sheet.ios.tsx | 13 +- app/profile-sheet.tsx | 13 +- .../out-of-office/CreateOutOfOfficeModal.tsx | 346 +++++++++++++++ .../out-of-office/OutOfOfficeListItem.tsx | 135 ++++++ .../out-of-office/OutOfOfficeListSkeleton.tsx | 48 +++ components/screens/OutOfOfficeScreen.tsx | 337 +++++++++++++++ hooks/index.ts | 7 + hooks/useOutOfOffice.ts | 56 +++ services/calcom/index.ts | 36 +- services/calcom/ooo.ts | 120 ++++++ services/types/index.ts | 1 + services/types/ooo.types.ts | 44 ++ 19 files changed, 1890 insertions(+), 32 deletions(-) create mode 100644 app/(tabs)/(ooo)/_layout.tsx create mode 100644 app/(tabs)/(ooo)/create-entry.tsx create mode 100644 app/(tabs)/(ooo)/index.ios.tsx create mode 100644 app/(tabs)/(ooo)/index.tsx create mode 100644 components/out-of-office/CreateOutOfOfficeModal.tsx create mode 100644 components/out-of-office/OutOfOfficeListItem.tsx create mode 100644 components/out-of-office/OutOfOfficeListSkeleton.tsx create mode 100644 components/screens/OutOfOfficeScreen.tsx create mode 100644 hooks/useOutOfOffice.ts create mode 100644 services/calcom/ooo.ts create mode 100644 services/types/ooo.types.ts diff --git a/app/(tabs)/(more)/index.ios.tsx b/app/(tabs)/(more)/index.ios.tsx index 205ae8d..97b1468 100644 --- a/app/(tabs)/(more)/index.ios.tsx +++ b/app/(tabs)/(more)/index.ios.tsx @@ -14,14 +14,14 @@ import { } from "react-native"; import { LandingPagePicker } from "@/components/LandingPagePicker"; import { LogoutConfirmModal } from "@/components/LogoutConfirmModal"; +import { getColors } from "@/constants/colors"; import { useAuth } from "@/contexts/AuthContext"; import { useQueryContext } from "@/contexts/QueryContext"; import { useUserProfile } from "@/hooks"; import { type LandingPage, useUserPreferences } from "@/hooks/useUserPreferences"; -import { showErrorAlert, showSuccessAlert, showNotAvailableAlert } from "@/utils/alerts"; +import { showErrorAlert, showNotAvailableAlert, showSuccessAlert } from "@/utils/alerts"; import { openInAppBrowser } from "@/utils/browser"; import { getAvatarUrl } from "@/utils/getAvatarUrl"; -import { getColors } from "@/constants/colors"; interface MoreMenuItem { name: string; @@ -91,6 +91,12 @@ export default function More() { // handleNotAvailable replaced by global showNotAvailableAlert const menuItems: MoreMenuItem[] = [ + { + name: "Out of Office", + icon: "airplane-outline", + isExternal: false, + onPress: () => router.push("/(tabs)/(ooo)"), + }, { name: "Apps", icon: "grid-outline", diff --git a/app/(tabs)/(more)/index.tsx b/app/(tabs)/(more)/index.tsx index 0479ce8..468bc1c 100644 --- a/app/(tabs)/(more)/index.tsx +++ b/app/(tabs)/(more)/index.tsx @@ -1,4 +1,5 @@ import { Ionicons } from "@expo/vector-icons"; +import { useRouter } from "expo-router"; import { useState } from "react"; import { Alert, @@ -12,12 +13,12 @@ import { import { Header } from "@/components/Header"; import { LandingPagePicker } from "@/components/LandingPagePicker"; import { LogoutConfirmModal } from "@/components/LogoutConfirmModal"; +import { getColors } from "@/constants/colors"; import { useAuth } from "@/contexts/AuthContext"; import { useQueryContext } from "@/contexts/QueryContext"; import { type LandingPage, useUserPreferences } from "@/hooks/useUserPreferences"; import { showErrorAlert, showSuccessAlert } from "@/utils/alerts"; import { openInAppBrowser } from "@/utils/browser"; -import { getColors } from "@/constants/colors"; interface MoreMenuItem { name: string; @@ -28,6 +29,7 @@ interface MoreMenuItem { } export default function More() { + const router = useRouter(); const { logout } = useAuth(); const { clearCache } = useQueryContext(); const { preferences, setLandingPage, landingPageLabel } = useUserPreferences(); @@ -80,6 +82,12 @@ export default function More() { }; const menuItems: MoreMenuItem[] = [ + { + name: "Out of Office", + icon: "airplane-outline", + isExternal: false, + onPress: () => router.push("/(tabs)/(ooo)"), + }, { name: "Apps", icon: "grid-outline", @@ -156,7 +164,10 @@ export default function More() { > + + + + ); +} diff --git a/app/(tabs)/(ooo)/create-entry.tsx b/app/(tabs)/(ooo)/create-entry.tsx new file mode 100644 index 0000000..e2811ea --- /dev/null +++ b/app/(tabs)/(ooo)/create-entry.tsx @@ -0,0 +1,399 @@ +import { Ionicons } from "@expo/vector-icons"; +import DateTimePicker from "@react-native-community/datetimepicker"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useEffect, useState } from "react"; +import { + Platform, + ScrollView, + Text, + TextInput, + TouchableOpacity, + useColorScheme, + View, +} from "react-native"; +import { AppPressable } from "@/components/AppPressable"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { getColors } from "@/constants/colors"; +import { useCreateOutOfOfficeEntry, useUpdateOutOfOfficeEntry } from "@/hooks/useOutOfOffice"; +import type { OutOfOfficeReason } from "@/services/types/ooo.types"; +import { showErrorAlert, showSuccessAlert } from "@/utils/alerts"; + +interface ReasonOption { + value: OutOfOfficeReason; + label: string; + emoji: string; +} + +const REASON_OPTIONS: ReasonOption[] = [ + { value: "unspecified", label: "Out of Office", emoji: "\u{1F3DD}\u{FE0F}" }, + { value: "vacation", label: "Vacation", emoji: "\u{1F3DD}\u{FE0F}" }, + { value: "travel", label: "Travel", emoji: "\u{2708}\u{FE0F}" }, + { value: "sick", label: "Sick Leave", emoji: "\u{1F912}" }, + { value: "public_holiday", label: "Public Holiday", emoji: "\u{1F389}" }, +]; + +export default function CreateOOOEntry() { + const router = useRouter(); + const params = useLocalSearchParams<{ + id?: string; + start?: string; + end?: string; + reason?: string; + notes?: string; + }>(); + + const isEditing = Boolean(params.id); + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + const theme = getColors(isDark); + + const colors = { + background: isDark ? "#000000" : "#FFFFFF", + backgroundSecondary: isDark ? "#171717" : "#F3F4F6", + border: isDark ? "#4D4D4D" : "#E5E5EA", + text: isDark ? "#FFFFFF" : "#333333", + textSecondary: isDark ? "#A3A3A3" : "#666666", + inputBackground: isDark ? "#262626" : "#FFFFFF", + }; + + const [startDate, setStartDate] = useState(() => { + if (params.start) { + return new Date(params.start); + } + return new Date(); + }); + + const [endDate, setEndDate] = useState(() => { + if (params.end) { + return new Date(params.end); + } + const nextWeek = new Date(); + nextWeek.setDate(nextWeek.getDate() + 7); + return nextWeek; + }); + + const [reason, setReason] = useState( + (params.reason as OutOfOfficeReason) || "unspecified" + ); + const [notes, setNotes] = useState(params.notes || ""); + + const [showStartPicker, setShowStartPicker] = useState(false); + const [showEndPicker, setShowEndPicker] = useState(false); + + const { mutate: createEntry, isPending: creating } = useCreateOutOfOfficeEntry(); + const { mutate: updateEntry, isPending: updating } = useUpdateOutOfOfficeEntry(); + + const isSubmitting = creating || updating; + + useEffect(() => { + if (params.start) { + setStartDate(new Date(params.start)); + } + if (params.end) { + setEndDate(new Date(params.end)); + } + if (params.reason) { + setReason(params.reason as OutOfOfficeReason); + } + if (params.notes) { + setNotes(params.notes); + } + }, [params.start, params.end, params.reason, params.notes]); + + const handleSubmit = () => { + if (endDate < startDate) { + showErrorAlert("Error", "End date must be after start date"); + return; + } + + const payload = { + start: startDate.toISOString().split("T")[0], + end: endDate.toISOString().split("T")[0], + reason, + notes: notes.trim() || undefined, + }; + + if (isEditing && params.id) { + updateEntry( + { id: parseInt(params.id, 10), ...payload }, + { + onSuccess: () => { + showSuccessAlert("Success", "Entry updated successfully"); + router.back(); + }, + onError: (error) => { + const message = error instanceof Error ? error.message : "Failed to update entry"; + showErrorAlert("Error", message); + }, + } + ); + } else { + createEntry(payload, { + onSuccess: () => { + showSuccessAlert("Success", "Entry created successfully"); + router.back(); + }, + onError: (error) => { + const message = error instanceof Error ? error.message : "Failed to create entry"; + showErrorAlert("Error", message); + }, + }); + } + }; + + const selectedReason = REASON_OPTIONS.find((r) => r.value === reason) || REASON_OPTIONS[0]; + + const formatDate = (date: Date): string => { + return date.toLocaleDateString("en-US", { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + }); + }; + + const handleStartDateChange = (_event: unknown, selectedDate?: Date) => { + if (Platform.OS === "android") { + setShowStartPicker(false); + } + if (selectedDate) { + setStartDate(selectedDate); + if (selectedDate > endDate) { + const newEndDate = new Date(selectedDate); + newEndDate.setDate(newEndDate.getDate() + 7); + setEndDate(newEndDate); + } + } + }; + + const handleEndDateChange = (_event: unknown, selectedDate?: Date) => { + if (Platform.OS === "android") { + setShowEndPicker(false); + } + if (selectedDate) { + setEndDate(selectedDate); + } + }; + + return ( + <> + ( + router.back()} disabled={isSubmitting}> + Cancel + + ), + headerRight: () => ( + + + {isSubmitting ? "Saving..." : "Save"} + + + ), + }} + /> + + + + + Dates + + + + + + Start Date + + setShowStartPicker(true)} + style={{ + backgroundColor: colors.inputBackground, + borderWidth: 1, + borderColor: colors.border, + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 12, + }} + > + {formatDate(startDate)} + + + + + + End Date + + setShowEndPicker(true)} + style={{ + backgroundColor: colors.inputBackground, + borderWidth: 1, + borderColor: colors.border, + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 12, + }} + > + {formatDate(endDate)} + + + + + {(showStartPicker || Platform.OS === "ios") && ( + + + Select Start Date + + + + )} + + {(showEndPicker || Platform.OS === "ios") && ( + + + Select End Date + + + + )} + + + + + Reason + + + + + + + {selectedReason.emoji} + {selectedReason.label} + + + + + + + {REASON_OPTIONS.map((option) => ( + setReason(option.value)} + > + + {option.emoji} + {option.label} + + + ))} + + + + + + + Notes (optional) + + + + + + + + + + + Note: Full out of office functionality requires API v2 user-level endpoints which + are not yet available. This feature is currently in preview mode. + + + + + + + ); +} diff --git a/app/(tabs)/(ooo)/index.ios.tsx b/app/(tabs)/(ooo)/index.ios.tsx new file mode 100644 index 0000000..6a2f0c3 --- /dev/null +++ b/app/(tabs)/(ooo)/index.ios.tsx @@ -0,0 +1,313 @@ +import { Ionicons } from "@expo/vector-icons"; +import { isLiquidGlassAvailable } from "expo-glass-effect"; +import { Image } from "expo-image"; +import { Stack, useRouter } from "expo-router"; +import { useState } from "react"; +import { + Alert, + FlatList, + Pressable, + RefreshControl, + ScrollView, + Text, + TouchableOpacity, + useColorScheme, + View, +} from "react-native"; +import { EmptyScreen } from "@/components/EmptyScreen"; +import { OutOfOfficeListItem } from "@/components/out-of-office/OutOfOfficeListItem"; +import { OutOfOfficeListSkeleton } from "@/components/out-of-office/OutOfOfficeListSkeleton"; +import { getColors } from "@/constants/colors"; +import { useUserProfile } from "@/hooks"; +import { useDeleteOutOfOfficeEntry, useOutOfOfficeEntries } from "@/hooks/useOutOfOffice"; +import type { OutOfOfficeEntry } from "@/services/types/ooo.types"; +import { showErrorAlert, showSuccessAlert } from "@/utils/alerts"; +import { getAvatarUrl } from "@/utils/getAvatarUrl"; +import { offlineAwareRefresh } from "@/utils/network"; + +export default function OutOfOfficeIOS() { + const router = useRouter(); + const [searchQuery, setSearchQuery] = useState(""); + const [isManualRefreshing, setIsManualRefreshing] = useState(false); + const { data: userProfile } = useUserProfile(); + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + const theme = getColors(isDark); + + const { + data: entries = [], + isLoading: loading, + error: queryError, + refetch, + } = useOutOfOfficeEntries(); + const { mutate: deleteEntryMutation } = useDeleteOutOfOfficeEntry(); + + const isAuthError = + queryError?.message?.includes("Authentication") || + queryError?.message?.includes("sign in") || + queryError?.message?.includes("401"); + const error = + queryError && !isAuthError && __DEV__ ? "Failed to load out of office entries." : null; + + const filteredEntries = entries.filter((entry) => { + if (!searchQuery.trim()) return true; + const searchLower = searchQuery.toLowerCase(); + const reasonText = entry.reason?.toLowerCase() || ""; + const notesText = entry.notes?.toLowerCase() || ""; + return reasonText.includes(searchLower) || notesText.includes(searchLower); + }); + + const onRefresh = async () => { + setIsManualRefreshing(true); + await offlineAwareRefresh(refetch).finally(() => { + setIsManualRefreshing(false); + }); + }; + + const handleSearch = (query: string) => { + setSearchQuery(query); + }; + + const handleCreateNew = () => { + router.push("/(tabs)/(ooo)/create-entry"); + }; + + const handleEdit = (entry: OutOfOfficeEntry) => { + router.push({ + pathname: "/(tabs)/(ooo)/create-entry", + params: { + id: entry.id.toString(), + start: entry.start, + end: entry.end, + reason: entry.reason || "unspecified", + notes: entry.notes || "", + }, + }); + }; + + const handleDelete = (entry: OutOfOfficeEntry) => { + Alert.alert("Delete Entry", "Are you sure you want to delete this out of office entry?", [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + deleteEntryMutation(entry.id, { + onSuccess: () => { + showSuccessAlert("Success", "Entry deleted successfully"); + }, + onError: () => { + showErrorAlert("Error", "Failed to delete entry. Please try again."); + }, + }); + }, + }, + ]); + }; + + const getReasonEmoji = (reason?: string): string => { + switch (reason) { + case "vacation": + return "\u{1F3DD}\u{FE0F}"; + case "travel": + return "\u{2708}\u{FE0F}"; + case "sick": + return "\u{1F912}"; + case "public_holiday": + return "\u{1F389}"; + default: + return "\u{1F3DD}\u{FE0F}"; + } + }; + + const getReasonLabel = (reason?: string): string => { + switch (reason) { + case "vacation": + return "Vacation"; + case "travel": + return "Travel"; + case "sick": + return "Sick Leave"; + case "public_holiday": + return "Public Holiday"; + default: + return "Out of Office"; + } + }; + + if (loading) { + return ( + <> + + Out of Office + + + + + + ); + } + + if (error) { + return ( + <> + + Out of Office + + + + + Unable to load entries + + + {error} + + refetch()} + > + + Retry + + + + + ); + } + + if (entries.length === 0) { + return ( + <> + + Out of Office + + {userProfile?.avatarUrl ? ( + + router.push("/profile-sheet")}> + + + + ) : ( + router.push("/profile-sheet")}> + + + )} + + + + + + + ); + } + + return ( + <> + + Out of Office + + + New + + {userProfile?.avatarUrl ? ( + + router.push("/profile-sheet")}> + + + + ) : ( + router.push("/profile-sheet")}> + + + )} + + handleSearch(e.nativeEvent.text)} + obscureBackground={false} + barTintColor={isDark ? "#171717" : "#fff"} + /> + + + {filteredEntries.length === 0 && searchQuery.trim() !== "" ? ( + } + showsVerticalScrollIndicator={false} + contentInsetAdjustmentBehavior="automatic" + > + + + + + ) : ( + item.id.toString()} + renderItem={({ item }) => ( + handleEdit(item)} + onDelete={() => handleDelete(item)} + getReasonEmoji={getReasonEmoji} + getReasonLabel={getReasonLabel} + /> + )} + refreshControl={} + showsVerticalScrollIndicator={false} + contentInsetAdjustmentBehavior="automatic" + ItemSeparatorComponent={() => } + /> + )} + + ); +} diff --git a/app/(tabs)/(ooo)/index.tsx b/app/(tabs)/(ooo)/index.tsx new file mode 100644 index 0000000..471112f --- /dev/null +++ b/app/(tabs)/(ooo)/index.tsx @@ -0,0 +1,5 @@ +import { OutOfOfficeScreen } from "@/components/screens/OutOfOfficeScreen"; + +export default function OutOfOfficePage() { + return ; +} diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index dd72a6c..9e0e896 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,10 +1,9 @@ import { Ionicons } from "@expo/vector-icons"; -import type { ColorValue, ImageSourcePropType } from "react-native"; -import { Tabs, VectorIcon, useRouter } from "expo-router"; +import { Tabs, useRouter, VectorIcon } from "expo-router"; import { NativeTabs } from "expo-router/unstable-native-tabs"; -import { useColorScheme } from "react-native"; -import { Platform } from "react-native"; import { useEffect, useRef } from "react"; +import type { ColorValue, ImageSourcePropType } from "react-native"; +import { Platform, useColorScheme } from "react-native"; import { getRouteFromPreference, useUserPreferences } from "@/hooks/useUserPreferences"; // Type for vector icon families that support getImageSource @@ -180,6 +179,13 @@ function WebTabs({ colors }: { colors: TabColors }) { }} /> + + - openInAppBrowser( - "https://app.cal.com/settings/my-account/out-of-office", - "Out of Office page" - ), - external: true, + icon: "airplane-outline", + onPress: () => { + router.back(); + router.push("/(tabs)/(ooo)"); + }, + external: false, }, { diff --git a/app/profile-sheet.tsx b/app/profile-sheet.tsx index e341e1c..996dc8c 100644 --- a/app/profile-sheet.tsx +++ b/app/profile-sheet.tsx @@ -66,13 +66,12 @@ export default function ProfileSheet() { { id: "outOfOffice", label: "Out of Office", - icon: "moon-outline", - onPress: () => - openInAppBrowser( - "https://app.cal.com/settings/my-account/out-of-office", - "Out of Office page" - ), - external: true, + icon: "airplane-outline", + onPress: () => { + router.back(); + router.push("/(tabs)/(ooo)"); + }, + external: false, }, { id: "publicPage", diff --git a/components/out-of-office/CreateOutOfOfficeModal.tsx b/components/out-of-office/CreateOutOfOfficeModal.tsx new file mode 100644 index 0000000..0338c53 --- /dev/null +++ b/components/out-of-office/CreateOutOfOfficeModal.tsx @@ -0,0 +1,346 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useEffect, useState } from "react"; +import { + Modal, + Platform, + ScrollView, + Text, + TextInput, + TouchableOpacity, + useColorScheme, + View, +} from "react-native"; +import { AppPressable } from "@/components/AppPressable"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { getColors } from "@/constants/colors"; +import { useCreateOutOfOfficeEntry, useUpdateOutOfOfficeEntry } from "@/hooks/useOutOfOffice"; +import type { OutOfOfficeEntry, OutOfOfficeReason } from "@/services/types/ooo.types"; +import { showErrorAlert, showSuccessAlert } from "@/utils/alerts"; + +interface CreateOutOfOfficeModalProps { + visible: boolean; + onClose: () => void; + editingEntry: OutOfOfficeEntry | null; + onSuccess: () => void; +} + +interface ReasonOption { + value: OutOfOfficeReason; + label: string; + emoji: string; +} + +const REASON_OPTIONS: ReasonOption[] = [ + { value: "unspecified", label: "Out of Office", emoji: "\u{1F3DD}\u{FE0F}" }, + { value: "vacation", label: "Vacation", emoji: "\u{1F3DD}\u{FE0F}" }, + { value: "travel", label: "Travel", emoji: "\u{2708}\u{FE0F}" }, + { value: "sick", label: "Sick Leave", emoji: "\u{1F912}" }, + { value: "public_holiday", label: "Public Holiday", emoji: "\u{1F389}" }, +]; + +export function CreateOutOfOfficeModal({ + visible, + onClose, + editingEntry, + onSuccess, +}: CreateOutOfOfficeModalProps) { + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + const theme = getColors(isDark); + + const colors = { + background: isDark ? "#171717" : "#FFFFFF", + backgroundSecondary: isDark ? "#2C2C2E" : "#F3F4F6", + border: isDark ? "#4D4D4D" : "#E5E5EA", + text: isDark ? "#FFFFFF" : "#333333", + textSecondary: isDark ? "#A3A3A3" : "#666666", + inputBackground: isDark ? "#262626" : "#FFFFFF", + }; + + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [reason, setReason] = useState("unspecified"); + const [notes, setNotes] = useState(""); + + const { mutate: createEntry, isPending: creating } = useCreateOutOfOfficeEntry(); + const { mutate: updateEntry, isPending: updating } = useUpdateOutOfOfficeEntry(); + + const isSubmitting = creating || updating; + + useEffect(() => { + if (editingEntry) { + setStartDate(editingEntry.start.split("T")[0]); + setEndDate(editingEntry.end.split("T")[0]); + setReason(editingEntry.reason || "unspecified"); + setNotes(editingEntry.notes || ""); + } else { + const today = new Date(); + const nextWeek = new Date(today); + nextWeek.setDate(today.getDate() + 7); + setStartDate(today.toISOString().split("T")[0]); + setEndDate(nextWeek.toISOString().split("T")[0]); + setReason("unspecified"); + setNotes(""); + } + }, [editingEntry]); + + const handleSubmit = () => { + if (!startDate || !endDate) { + showErrorAlert("Error", "Please select both start and end dates"); + return; + } + + const startDateObj = new Date(startDate); + const endDateObj = new Date(endDate); + + if (endDateObj < startDateObj) { + showErrorAlert("Error", "End date must be after start date"); + return; + } + + const payload = { + start: startDate, + end: endDate, + reason, + notes: notes.trim() || undefined, + }; + + if (editingEntry) { + updateEntry( + { id: editingEntry.id, ...payload }, + { + onSuccess: () => { + showSuccessAlert("Success", "Entry updated successfully"); + onSuccess(); + }, + onError: (error) => { + const message = error instanceof Error ? error.message : "Failed to update entry"; + showErrorAlert("Error", message); + }, + } + ); + } else { + createEntry(payload, { + onSuccess: () => { + showSuccessAlert("Success", "Entry created successfully"); + onSuccess(); + }, + onError: (error) => { + const message = error instanceof Error ? error.message : "Failed to create entry"; + showErrorAlert("Error", message); + }, + }); + } + }; + + const selectedReason = REASON_OPTIONS.find((r) => r.value === reason) || REASON_OPTIONS[0]; + + return ( + + + + + Cancel + + + + {editingEntry ? "Edit Entry" : "New Entry"} + + + + + {isSubmitting ? "Saving..." : "Save"} + + + + + + + + Dates + + + + + + Start Date + + + + + + + End Date + + + + + + + + + Reason + + + + + + + {selectedReason.emoji} + {selectedReason.label} + + + + + + + {REASON_OPTIONS.map((option) => ( + setReason(option.value)} + > + + {option.emoji} + {option.label} + + + ))} + + + + + + + Notes (optional) + + + + + + + + + + + Note: Full out of office functionality requires API v2 user-level endpoints which + are not yet available. This feature is currently in preview mode. + + + + + + + + ); +} diff --git a/components/out-of-office/OutOfOfficeListItem.tsx b/components/out-of-office/OutOfOfficeListItem.tsx new file mode 100644 index 0000000..e87bae1 --- /dev/null +++ b/components/out-of-office/OutOfOfficeListItem.tsx @@ -0,0 +1,135 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Text, TouchableOpacity, useColorScheme, View } from "react-native"; +import { getColors } from "@/constants/colors"; +import type { OutOfOfficeEntry } from "@/services/types/ooo.types"; + +interface OutOfOfficeListItemProps { + entry: OutOfOfficeEntry; + onEdit: () => void; + onDelete: () => void; + getReasonEmoji: (reason?: string) => string; + getReasonLabel: (reason?: string) => string; +} + +export function OutOfOfficeListItem({ + entry, + onEdit, + onDelete, + getReasonEmoji, + getReasonLabel, +}: OutOfOfficeListItemProps) { + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + const theme = getColors(isDark); + + const colors = { + background: isDark ? "#171717" : "#FFFFFF", + border: isDark ? "#4D4D4D" : "#E5E5EA", + text: isDark ? "#FFFFFF" : "#333333", + textSecondary: isDark ? "#A3A3A3" : "#666666", + emojiBackground: isDark ? "#2C2C2E" : "#F3F4F6", + }; + + const formatDate = (dateString: string): string => { + const date = new Date(dateString); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + }; + + const startDate = formatDate(entry.start); + const endDate = formatDate(entry.end); + + return ( + + + + + {getReasonEmoji(entry.reason)} + + + + + {startDate} - {endDate} + + + + {getReasonLabel(entry.reason)} + + + {entry.toUser && ( + + Forwarding to{" "} + + {entry.toUser.name || entry.toUser.username} + + + )} + + {entry.notes && ( + + {entry.notes} + + )} + + + + + + + + + + + + + + + ); +} diff --git a/components/out-of-office/OutOfOfficeListSkeleton.tsx b/components/out-of-office/OutOfOfficeListSkeleton.tsx new file mode 100644 index 0000000..2afecac --- /dev/null +++ b/components/out-of-office/OutOfOfficeListSkeleton.tsx @@ -0,0 +1,48 @@ +import { useColorScheme, View } from "react-native"; +import { Skeleton } from "@/components/ui/skeleton"; + +export function OutOfOfficeListSkeleton() { + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + + const colors = { + background: isDark ? "#171717" : "#FFFFFF", + border: isDark ? "#4D4D4D" : "#E5E5EA", + }; + + const SkeletonItem = () => ( + + + + + + + + + + + + + + + + + ); + + return ( + + + + + + ); +} diff --git a/components/screens/OutOfOfficeScreen.tsx b/components/screens/OutOfOfficeScreen.tsx new file mode 100644 index 0000000..1a91e85 --- /dev/null +++ b/components/screens/OutOfOfficeScreen.tsx @@ -0,0 +1,337 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useRouter } from "expo-router"; +import { useState } from "react"; +import { + Alert, + FlatList, + Platform, + RefreshControl, + ScrollView, + Text, + TouchableOpacity, + useColorScheme, + View, +} from "react-native"; +import { AppPressable } from "@/components/AppPressable"; +import { EmptyScreen } from "@/components/EmptyScreen"; +import { OutOfOfficeListItem } from "@/components/out-of-office/OutOfOfficeListItem"; +import { OutOfOfficeListSkeleton } from "@/components/out-of-office/OutOfOfficeListSkeleton"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Text as AlertDialogText } from "@/components/ui/text"; +import { getColors } from "@/constants/colors"; +import { useDeleteOutOfOfficeEntry, useOutOfOfficeEntries } from "@/hooks/useOutOfOffice"; +import type { OutOfOfficeEntry } from "@/services/types/ooo.types"; +import { showErrorAlert, showSuccessAlert } from "@/utils/alerts"; +import { offlineAwareRefresh } from "@/utils/network"; + +export interface OutOfOfficeScreenProps { + searchQuery?: string; + onSearchChange?: (query: string) => void; +} + +export function OutOfOfficeScreen({ searchQuery = "" }: OutOfOfficeScreenProps) { + const router = useRouter(); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [selectedEntry, setSelectedEntry] = useState(null); + const [isManualRefreshing, setIsManualRefreshing] = useState(false); + + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + const theme = getColors(isDark); + + const colors = { + background: isDark ? "#000000" : "#FFFFFF", + backgroundSecondary: isDark ? "#171717" : "#f8f9fa", + border: isDark ? "#4D4D4D" : "#E5E5EA", + text: isDark ? "#FFFFFF" : "#333333", + textSecondary: isDark ? "#A3A3A3" : "#666666", + }; + + const { + data: entries = [], + isLoading: loading, + error: queryError, + refetch, + } = useOutOfOfficeEntries(); + const { mutate: deleteEntryMutation, isPending: deleting } = useDeleteOutOfOfficeEntry(); + + const isAuthError = + queryError?.message?.includes("Authentication") || + queryError?.message?.includes("sign in") || + queryError?.message?.includes("401"); + const error = + queryError && !isAuthError && __DEV__ ? "Failed to load out of office entries." : null; + + const filteredEntries = entries.filter((entry) => { + if (!searchQuery.trim()) return true; + const searchLower = searchQuery.toLowerCase(); + const reasonText = entry.reason?.toLowerCase() || ""; + const notesText = entry.notes?.toLowerCase() || ""; + return reasonText.includes(searchLower) || notesText.includes(searchLower); + }); + + const onRefresh = async () => { + setIsManualRefreshing(true); + await offlineAwareRefresh(refetch).finally(() => { + setIsManualRefreshing(false); + }); + }; + + const handleCreateNew = () => { + router.push("/(tabs)/(ooo)/create-entry"); + }; + + const handleEdit = (entry: OutOfOfficeEntry) => { + router.push({ + pathname: "/(tabs)/(ooo)/create-entry", + params: { + id: entry.id.toString(), + start: entry.start, + end: entry.end, + reason: entry.reason || "unspecified", + notes: entry.notes || "", + }, + }); + }; + + const handleDelete = (entry: OutOfOfficeEntry) => { + if (Platform.OS === "web") { + setSelectedEntry(entry); + setShowDeleteModal(true); + } else { + Alert.alert("Delete Entry", "Are you sure you want to delete this out of office entry?", [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + deleteEntryMutation(entry.id, { + onSuccess: () => { + showSuccessAlert("Success", "Entry deleted successfully"); + }, + onError: () => { + showErrorAlert("Error", "Failed to delete entry. Please try again."); + }, + }); + }, + }, + ]); + } + }; + + const confirmDelete = () => { + if (!selectedEntry) return; + + deleteEntryMutation(selectedEntry.id, { + onSuccess: () => { + setShowDeleteModal(false); + setSelectedEntry(null); + showSuccessAlert("Success", "Entry deleted successfully"); + }, + onError: () => { + showErrorAlert("Error", "Failed to delete entry. Please try again."); + }, + }); + }; + + const getReasonEmoji = (reason?: string): string => { + switch (reason) { + case "vacation": + return "\u{1F3DD}\u{FE0F}"; + case "travel": + return "\u{2708}\u{FE0F}"; + case "sick": + return "\u{1F912}"; + case "public_holiday": + return "\u{1F389}"; + default: + return "\u{1F3DD}\u{FE0F}"; + } + }; + + const getReasonLabel = (reason?: string): string => { + switch (reason) { + case "vacation": + return "Vacation"; + case "travel": + return "Travel"; + case "sick": + return "Sick Leave"; + case "public_holiday": + return "Public Holiday"; + default: + return "Out of Office"; + } + }; + + if (loading) { + return ( + + + + + + ); + } + + if (error) { + return ( + + + + + Unable to load entries + + + {error} + + refetch()} + > + Retry + + + + ); + } + + const showEmptyState = entries.length === 0 && !loading; + const showSearchEmptyState = + filteredEntries.length === 0 && searchQuery.trim() !== "" && !showEmptyState; + const showList = !showEmptyState && !showSearchEmptyState; + + return ( + <> + + + Out of Office + + + + New + + + + {showEmptyState && ( + } + contentInsetAdjustmentBehavior="automatic" + > + + + )} + + {showSearchEmptyState && ( + } + > + + + )} + + {showList && ( + item.id.toString()} + renderItem={({ item }) => ( + handleEdit(item)} + onDelete={() => handleDelete(item)} + getReasonEmoji={getReasonEmoji} + getReasonLabel={getReasonLabel} + /> + )} + refreshControl={} + showsVerticalScrollIndicator={false} + contentInsetAdjustmentBehavior="automatic" + ItemSeparatorComponent={() => } + /> + )} + + + + + + Delete Entry + + + + Are you sure you want to delete this out of office entry? + + + + + { + setShowDeleteModal(false); + setSelectedEntry(null); + }} + disabled={deleting} + > + Cancel + + + Delete + + + + + + ); +} diff --git a/hooks/index.ts b/hooks/index.ts index 98d341f..d79f690 100644 --- a/hooks/index.ts +++ b/hooks/index.ts @@ -48,6 +48,13 @@ export { usePrefetchEventTypes, useUpdateEventType, } from "./useEventTypes"; +// Out of Office hooks +export { + useCreateOutOfOfficeEntry, + useDeleteOutOfOfficeEntry, + useOutOfOfficeEntries, + useUpdateOutOfOfficeEntry, +} from "./useOutOfOffice"; // Schedules (Availability) hooks export { type CreateScheduleInput, diff --git a/hooks/useOutOfOffice.ts b/hooks/useOutOfOffice.ts new file mode 100644 index 0000000..4c2cf00 --- /dev/null +++ b/hooks/useOutOfOffice.ts @@ -0,0 +1,56 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + createOutOfOfficeEntry, + deleteOutOfOfficeEntry, + getOutOfOfficeEntries, + updateOutOfOfficeEntry, +} from "@/services/calcom/ooo"; +import type { + CreateOutOfOfficeEntryInput, + OutOfOfficeEntry, + UpdateOutOfOfficeEntryInput, +} from "@/services/types/ooo.types"; + +const OOO_QUERY_KEY = ["outOfOfficeEntries"]; + +export function useOutOfOfficeEntries() { + return useQuery({ + queryKey: OOO_QUERY_KEY, + queryFn: () => getOutOfOfficeEntries(), + staleTime: 5 * 60 * 1000, + retry: 1, + }); +} + +export function useCreateOutOfOfficeEntry() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input) => createOutOfOfficeEntry(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: OOO_QUERY_KEY }); + }, + }); +} + +export function useUpdateOutOfOfficeEntry() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, ...input }) => updateOutOfOfficeEntry(id, input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: OOO_QUERY_KEY }); + }, + }); +} + +export function useDeleteOutOfOfficeEntry() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id) => deleteOutOfOfficeEntry(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: OOO_QUERY_KEY }); + }, + }); +} diff --git a/services/calcom/index.ts b/services/calcom/index.ts index 4d4abe8..744acf9 100644 --- a/services/calcom/index.ts +++ b/services/calcom/index.ts @@ -18,22 +18,26 @@ // Re-export types for backward compatibility export type { - EventType, - CreateEventTypeInput, Booking, + BookingLimitsCount, + BookingLimitsDuration, BookingParticipationResult, - Schedule, - UserProfile, ConferencingOption, - Webhook, + ConfirmationPolicy, + CreateEventTypeInput, + CreateOutOfOfficeEntryInput, + CreatePrivateLinkInput, CreateWebhookInput, - UpdateWebhookInput, + EventType, + OutOfOfficeEntry, + OutOfOfficeReason, PrivateLink, - CreatePrivateLinkInput, + Schedule, + UpdateOutOfOfficeEntryInput, UpdatePrivateLinkInput, - BookingLimitsCount, - BookingLimitsDuration, - ConfirmationPolicy, + UpdateWebhookInput, + UserProfile, + Webhook, } from "../types"; // Import all functions from submodules @@ -67,6 +71,12 @@ import { getEventTypes, updateEventType, } from "./event-types"; +import { + createOutOfOfficeEntry, + deleteOutOfOfficeEntry, + getOutOfOfficeEntries, + updateOutOfOfficeEntry, +} from "./ooo"; import { createEventTypePrivateLink, deleteEventTypePrivateLink, @@ -169,4 +179,10 @@ export const CalComAPIService = { createEventTypePrivateLink, updateEventTypePrivateLink, deleteEventTypePrivateLink, + + // Out of Office + getOutOfOfficeEntries, + createOutOfOfficeEntry, + updateOutOfOfficeEntry, + deleteOutOfOfficeEntry, }; diff --git a/services/calcom/ooo.ts b/services/calcom/ooo.ts new file mode 100644 index 0000000..5741698 --- /dev/null +++ b/services/calcom/ooo.ts @@ -0,0 +1,120 @@ +import type { + CreateOutOfOfficeEntryInput, + GetOutOfOfficeEntriesResponse, + GetOutOfOfficeEntryResponse, + OutOfOfficeEntry, + UpdateOutOfOfficeEntryInput, +} from "../types/ooo.types"; + +import { makeRequest } from "./request"; + +export async function getOutOfOfficeEntries(filters?: { + skip?: number; + take?: number; + sortStart?: "asc" | "desc"; + sortEnd?: "asc" | "desc"; +}): Promise { + const params = new URLSearchParams(); + + if (filters?.skip !== undefined) { + params.append("skip", filters.skip.toString()); + } + if (filters?.take !== undefined) { + params.append("take", filters.take.toString()); + } + if (filters?.sortStart) { + params.append("sortStart", filters.sortStart); + } + if (filters?.sortEnd) { + params.append("sortEnd", filters.sortEnd); + } + + const queryString = params.toString(); + const endpoint = `/me/out-of-office${queryString ? `?${queryString}` : ""}`; + + try { + const response = await makeRequest(endpoint, { + headers: { + "cal-api-version": "2024-08-13", + }, + }); + + if (response?.data) { + return response.data; + } + + return []; + } catch (error) { + if (error instanceof Error && error.message.includes("404")) { + console.warn("OOO endpoint not available - user-level API v2 endpoints needed"); + return []; + } + throw error; + } +} + +export async function createOutOfOfficeEntry( + input: CreateOutOfOfficeEntryInput +): Promise { + const response = await makeRequest( + "/me/out-of-office", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "cal-api-version": "2024-08-13", + }, + body: JSON.stringify(input), + }, + "2024-08-13" + ); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from create OOO API"); +} + +export async function updateOutOfOfficeEntry( + oooId: number, + input: UpdateOutOfOfficeEntryInput +): Promise { + const response = await makeRequest( + `/me/out-of-office/${oooId}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "cal-api-version": "2024-08-13", + }, + body: JSON.stringify(input), + }, + "2024-08-13" + ); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from update OOO API"); +} + +export async function deleteOutOfOfficeEntry(oooId: number): Promise { + const response = await makeRequest( + `/me/out-of-office/${oooId}`, + { + method: "DELETE", + headers: { + "cal-api-version": "2024-08-13", + }, + }, + "2024-08-13" + ); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from delete OOO API"); +} diff --git a/services/types/index.ts b/services/types/index.ts index dad7218..0292531 100644 --- a/services/types/index.ts +++ b/services/types/index.ts @@ -2,6 +2,7 @@ export * from "./bookings.types"; export * from "./event-types.types"; +export * from "./ooo.types"; export * from "./private-links.types"; export * from "./schedules.types"; export * from "./users.types"; diff --git a/services/types/ooo.types.ts b/services/types/ooo.types.ts new file mode 100644 index 0000000..a4c52ae --- /dev/null +++ b/services/types/ooo.types.ts @@ -0,0 +1,44 @@ +export type OutOfOfficeReason = "unspecified" | "vacation" | "travel" | "sick" | "public_holiday"; + +export interface OutOfOfficeEntry { + id: number; + uuid: string; + userId: number; + start: string; + end: string; + notes?: string; + reason?: OutOfOfficeReason; + toUserId?: number; + toUser?: { + id: number; + username?: string; + name?: string; + email?: string; + }; +} + +export interface GetOutOfOfficeEntriesResponse { + status: "success" | "error"; + data: OutOfOfficeEntry[]; +} + +export interface GetOutOfOfficeEntryResponse { + status: "success" | "error"; + data: OutOfOfficeEntry; +} + +export interface CreateOutOfOfficeEntryInput { + start: string; + end: string; + notes?: string; + reason?: OutOfOfficeReason; + toUserId?: number; +} + +export interface UpdateOutOfOfficeEntryInput { + start?: string; + end?: string; + notes?: string; + reason?: OutOfOfficeReason; + toUserId?: number; +}