From aa127b2c2b2e68d541f8dc79ebc7254a161637c6 Mon Sep 17 00:00:00 2001
From: Beto <43630417+betomoedano@users.noreply.github.com>
Date: Sun, 21 Dec 2025 05:38:42 -0600
Subject: [PATCH] refactor: (companion) (iOS) create an ios screen for specific
native logic (#26081)
* create an ios screen for specific native logic
* feat: update bookings layout to conditionally show header on iOS
* feat: add useActiveBookingFilter hook for managing booking filter state
- Introduced a new hook, useActiveBookingFilter, to manage the active booking filter state and provide related utilities.
- Added filter options for "upcoming", "unconfirmed", "past", and "cancelled" bookings.
- Updated index.ts to export the new hook and its types for UI state management.
* refactor: streamline booking filter management and remove unused code
- Integrated the useActiveBookingFilter hook to manage booking filter state, simplifying the filter logic.
- Removed redundant state variables and functions related to booking filters.
- Cleaned up the code by eliminating unused imports and commented-out code sections.
- Updated the booking fetching logic to utilize the new filter parameters directly.
* refactor: move meeting URL extraction and info retrieval to utility module
- Extracted meeting URL extraction and meeting info retrieval logic into a new utility module, meetings-utils.ts.
- Updated index.ios.tsx and index.tsx to utilize the new getMeetingInfo function from the utility module, simplifying the component code.
- Removed redundant helper functions from the component files to enhance maintainability.
* refactor: extract empty state content logic into utility module
- Moved the getEmptyStateContent function from the Bookings component files to a new utility module, bookings-utils.ts, to enhance code reusability and maintainability.
- Updated index.ios.tsx and index.tsx to utilize the new utility function, simplifying the component code and improving clarity.
- Removed redundant empty state logic from the component files.
* refactor: enhance booking filtering and utility functions
- Updated the Bookings component to utilize new utility functions for filtering bookings by event type and search queries, improving code clarity and maintainability.
- Moved formatting functions (formatTime, formatDate, formatMonthYear, getMonthYearKey) and grouping logic (groupBookingsByMonth) to the bookings-utils module, centralizing related functionality.
- Removed redundant code from the Bookings component files, streamlining the overall structure.
* refactor: implement booking actions hook for improved management
- Introduced a new `useBookingActions` hook to centralize booking-related actions such as rescheduling, confirming, and rejecting bookings, enhancing code organization and reusability.
- Updated the `Bookings` component to utilize the new hook, simplifying the component logic and reducing redundancy.
- Removed outdated state management and functions related to booking actions, streamlining the overall code structure.
* refactor: consolidate booking components and enhance structure
- Refactored the Bookings component to utilize a new BookingListScreen component, improving code organization and readability.
- Introduced BookingListItem for individual booking rendering, streamlining the rendering logic and enhancing maintainability.
- Removed redundant state management and functions from the Bookings component, simplifying the overall structure.
- Updated modal handling for booking actions, rescheduling, and rejection to improve user experience and code clarity.
* feat: add BookingListItem component for enhanced booking display
- Introduced a new BookingListItem component for rendering individual booking details, improving modularity and reusability.
- Implemented context menu actions for booking management, including rescheduling, editing location, and adding guests.
- Updated type definitions for BookingListItemProps to include additional action handlers for better functionality.
- Refactored existing BookingListItem component to utilize the new props structure, enhancing clarity and maintainability.
* refactor: simplify layout and styling in BookingListItem and BookingListScreen components
- Updated the BookingListItem component to streamline the layout by reducing padding and adjusting the gap between elements.
- Modified the BookingListScreen component to remove unnecessary horizontal padding in the content container style, enhancing the overall layout consistency.
---
companion/app/(tabs)/(bookings)/_layout.tsx | 10 +
companion/app/(tabs)/(bookings)/index.ios.tsx | 179 ++
companion/app/(tabs)/(bookings)/index.tsx | 153 ++
.../app/(tabs)/(event-types)/_layout.tsx | 2 +-
companion/app/(tabs)/_layout.tsx | 6 +-
companion/app/(tabs)/bookings.tsx | 1532 -----------------
.../booking-list-item/BookingListItem.ios.tsx | 262 +++
.../booking-list-item/BookingListItem.tsx | 158 ++
.../components/booking-list-item/types.ts | 22 +
.../booking-list-screen/BookingListScreen.tsx | 406 +++++
.../booking-modals/BookingModals.tsx | 347 ++++
companion/hooks/index.ts | 9 +
companion/hooks/useActiveBookingFilter.tsx | 135 ++
companion/hooks/useBookingActions.ts | 361 ++++
companion/utils/bookings-utils.ts | 359 ++++
companion/utils/meetings-utils.ts | 93 +
16 files changed, 2498 insertions(+), 1536 deletions(-)
create mode 100644 companion/app/(tabs)/(bookings)/_layout.tsx
create mode 100644 companion/app/(tabs)/(bookings)/index.ios.tsx
create mode 100644 companion/app/(tabs)/(bookings)/index.tsx
delete mode 100644 companion/app/(tabs)/bookings.tsx
create mode 100644 companion/components/booking-list-item/BookingListItem.ios.tsx
create mode 100644 companion/components/booking-list-item/BookingListItem.tsx
create mode 100644 companion/components/booking-list-item/types.ts
create mode 100644 companion/components/booking-list-screen/BookingListScreen.tsx
create mode 100644 companion/components/booking-modals/BookingModals.tsx
create mode 100644 companion/hooks/useActiveBookingFilter.tsx
create mode 100644 companion/hooks/useBookingActions.ts
create mode 100644 companion/utils/bookings-utils.ts
create mode 100644 companion/utils/meetings-utils.ts
diff --git a/companion/app/(tabs)/(bookings)/_layout.tsx b/companion/app/(tabs)/(bookings)/_layout.tsx
new file mode 100644
index 0000000000..4521f9ea72
--- /dev/null
+++ b/companion/app/(tabs)/(bookings)/_layout.tsx
@@ -0,0 +1,10 @@
+import { Stack } from "expo-router";
+import { Platform } from "react-native";
+
+export default function BookingsLayout() {
+ return (
+
+
+
+ );
+}
diff --git a/companion/app/(tabs)/(bookings)/index.ios.tsx b/companion/app/(tabs)/(bookings)/index.ios.tsx
new file mode 100644
index 0000000000..b1aadf2da4
--- /dev/null
+++ b/companion/app/(tabs)/(bookings)/index.ios.tsx
@@ -0,0 +1,179 @@
+import { isLiquidGlassAvailable } from "expo-glass-effect";
+import { Stack } from "expo-router";
+import React, { useState } from "react";
+import type { NativeStackHeaderItemMenuAction } from "@react-navigation/native-stack";
+
+import { BookingListScreen } from "../../../components/booking-list-screen/BookingListScreen";
+import { useActiveBookingFilter } from "../../../hooks/useActiveBookingFilter";
+import { useEventTypes } from "../../../hooks";
+
+export default function Bookings() {
+ const [searchQuery, setSearchQuery] = useState("");
+ const [selectedEventTypeId, setSelectedEventTypeId] = useState(null);
+ const { data: eventTypes } = useEventTypes();
+
+ // Use the active booking filter hook
+ const { activeFilter, filterOptions, filterParams, handleFilterChange } = useActiveBookingFilter(
+ "upcoming",
+ () => {
+ // Clear dependent filters when status filter changes
+ setSearchQuery("");
+ setSelectedEventTypeId(null);
+ }
+ );
+
+ const handleSearch = (query: string) => {
+ setSearchQuery(query);
+ };
+
+ const clearEventTypeFilter = () => {
+ setSelectedEventTypeId(null);
+ };
+
+ const buildActiveBookingFilter = () => {
+ const currentFilterOption = filterOptions.find((option) => option.key === activeFilter);
+ const statusMenuItems: NativeStackHeaderItemMenuAction[] = filterOptions.map((option) => {
+ const isSelected = activeFilter === option.key;
+ return {
+ type: "action",
+ label: option.label,
+ icon: {
+ name:
+ option.key === "upcoming"
+ ? "calendar.badge.clock"
+ : option.key === "unconfirmed"
+ ? "calendar.badge.exclamationmark"
+ : option.key === "past"
+ ? "calendar.badge.checkmark"
+ : "calendar.badge.minus",
+ type: "sfSymbol",
+ },
+ state: isSelected ? "on" : "off",
+ onPress: () => {
+ handleFilterChange(option.key);
+ },
+ } satisfies NativeStackHeaderItemMenuAction;
+ });
+
+ return {
+ type: "menu" as const,
+ label: currentFilterOption?.label || "Filter",
+ labelStyle: {
+ fontWeight: "600",
+ color: "#007AFF",
+ },
+ menu: {
+ title: "Filter by Status",
+ items: statusMenuItems,
+ },
+ };
+ };
+
+ const buildEventTypeFilterMenu = () => {
+ const eventTypeMenuItems: NativeStackHeaderItemMenuAction[] = (eventTypes || []).map(
+ (eventType) => {
+ const isSelected = selectedEventTypeId === eventType.id;
+ return {
+ type: "action",
+ label: eventType.title,
+ icon: {
+ name: "calendar.badge.clock",
+ type: "sfSymbol",
+ },
+ state: isSelected ? "on" : "off",
+ onPress: () => {
+ // Toggle behavior: if already selected, clear filter; otherwise, select it
+ if (isSelected) {
+ clearEventTypeFilter();
+ } else {
+ setSelectedEventTypeId(eventType.id);
+ }
+ },
+ };
+ }
+ );
+
+ const clearFilterItem: NativeStackHeaderItemMenuAction[] =
+ selectedEventTypeId !== null
+ ? [
+ {
+ type: "action",
+ label: "Clear filter",
+ icon: {
+ name: "xmark.circle",
+ type: "sfSymbol",
+ },
+ onPress: () => {
+ clearEventTypeFilter();
+ },
+ },
+ ]
+ : [];
+
+ const menuItems = [...eventTypeMenuItems, ...clearFilterItem];
+
+ return {
+ type: "menu" as const,
+ label: "Filter by Event Type",
+ icon: {
+ name: "line.3.horizontal.decrease",
+ type: "sfSymbol",
+ },
+ labelStyle: {
+ fontWeight: "600",
+ color: "#007AFF",
+ },
+ menu: {
+ title: menuItems.length > 0 ? "Filter by Event Type" : "No Event Types",
+ items: [
+ ...menuItems,
+ // Show message if no event types available
+ ...(menuItems.length === 0
+ ? [
+ {
+ type: "action",
+ label: "No event types available",
+ onPress: () => {},
+ } satisfies NativeStackHeaderItemMenuAction,
+ ]
+ : []),
+ ],
+ },
+ };
+ };
+
+ return (
+ <>
+ handleSearch(e.nativeEvent.text),
+ obscureBackground: false,
+ barTintColor: "#fff",
+ },
+ unstable_headerRightItems: () => {
+ const eventTypeFilterMenu = buildEventTypeFilterMenu();
+ const statusFilterMenu = buildActiveBookingFilter();
+
+ return [eventTypeFilterMenu, statusFilterMenu];
+ },
+ }}
+ />
+
+
+ >
+ );
+}
diff --git a/companion/app/(tabs)/(bookings)/index.tsx b/companion/app/(tabs)/(bookings)/index.tsx
new file mode 100644
index 0000000000..a561402882
--- /dev/null
+++ b/companion/app/(tabs)/(bookings)/index.tsx
@@ -0,0 +1,153 @@
+import { Ionicons } from "@expo/vector-icons";
+import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
+import SegmentedControl from "@react-native-segmented-control/segmented-control";
+import React, { useState } from "react";
+import { View, Text, TouchableOpacity, TextInput } from "react-native";
+
+import type { EventType } from "../../../services/calcom";
+import { CalComAPIService } from "../../../services/calcom";
+import { Header } from "../../../components/Header";
+import { BookingListScreen } from "../../../components/booking-list-screen/BookingListScreen";
+import { useActiveBookingFilter } from "../../../hooks/useActiveBookingFilter";
+
+export default function Bookings() {
+ const [searchQuery, setSearchQuery] = useState("");
+ const [showFilterModal, setShowFilterModal] = useState(false);
+ const [eventTypes, setEventTypes] = useState([]);
+ const [selectedEventTypeId, setSelectedEventTypeId] = useState(null);
+ const [selectedEventTypeLabel, setSelectedEventTypeLabel] = useState(null);
+ const [eventTypesLoading, setEventTypesLoading] = useState(false);
+
+ // Use the active booking filter hook
+ const { activeFilter, filterLabels, activeIndex, filterParams, handleSegmentChange } =
+ useActiveBookingFilter("upcoming", () => {
+ // Clear dependent filters when status filter changes
+ setSearchQuery("");
+ setSelectedEventTypeId(null);
+ setSelectedEventTypeLabel(null);
+ });
+
+ const handleSearch = (query: string) => {
+ setSearchQuery(query);
+ };
+
+ const fetchEventTypes = async () => {
+ try {
+ setEventTypesLoading(true);
+ const types = await CalComAPIService.getEventTypes();
+ setEventTypes(types);
+ } catch (err) {
+ console.error("Error fetching event types:", err);
+ // Error is logged but not displayed to user for event type filter
+ } finally {
+ setEventTypesLoading(false);
+ }
+ };
+
+ const handleFilterButtonPress = () => {
+ setShowFilterModal(true);
+ if (eventTypes.length === 0) {
+ fetchEventTypes();
+ }
+ };
+
+ const clearEventTypeFilter = () => {
+ setSelectedEventTypeId(null);
+ setSelectedEventTypeLabel(null);
+ };
+
+ const handleEventTypeSelect = (eventTypeId: number | null, label?: string | null) => {
+ if (eventTypeId === null) {
+ clearEventTypeFilter();
+ } else {
+ setSelectedEventTypeId(eventTypeId);
+ setSelectedEventTypeLabel(label || null);
+ }
+ setShowFilterModal(false);
+ };
+
+ const supportsLiquidGlass = isLiquidGlassAvailable();
+
+ const renderSegmentedControl = () => {
+ const segmentedControlContent = (
+
+ );
+
+ return (
+ <>
+ {supportsLiquidGlass ? (
+
+ {segmentedControlContent}
+
+ ) : (
+
+ {segmentedControlContent}
+
+ )}
+
+
+
+
+
+ Filter
+
+
+
+
+
+
+ {selectedEventTypeId !== null ? (
+
+
+ Filtered by {selectedEventTypeLabel || "event type"}
+
+
+ Clear filter
+
+
+ ) : null}
+
+ >
+ );
+ };
+
+ return (
+ }
+ renderFilterControls={renderSegmentedControl}
+ showFilterModal={showFilterModal}
+ setShowFilterModal={setShowFilterModal}
+ eventTypes={eventTypes}
+ eventTypesLoading={eventTypesLoading}
+ searchQuery={searchQuery}
+ selectedEventTypeId={selectedEventTypeId}
+ onEventTypeChange={handleEventTypeSelect}
+ activeFilter={activeFilter}
+ filterParams={filterParams}
+ />
+ );
+}
diff --git a/companion/app/(tabs)/(event-types)/_layout.tsx b/companion/app/(tabs)/(event-types)/_layout.tsx
index 92daf791ef..ca52620628 100644
--- a/companion/app/(tabs)/(event-types)/_layout.tsx
+++ b/companion/app/(tabs)/(event-types)/_layout.tsx
@@ -3,7 +3,7 @@ import { Stack } from "expo-router";
export default function EventTypesLayout() {
return (
-
+
);
}
diff --git a/companion/app/(tabs)/_layout.tsx b/companion/app/(tabs)/_layout.tsx
index 14705ceba4..1bb7fc2a93 100644
--- a/companion/app/(tabs)/_layout.tsx
+++ b/companion/app/(tabs)/_layout.tsx
@@ -20,7 +20,7 @@ export default function TabLayout() {
Event Types
-
+
}
@@ -76,7 +76,7 @@ function WebTabs() {
/>
(
@@ -86,7 +86,7 @@ function WebTabs() {
/>
(
diff --git a/companion/app/(tabs)/bookings.tsx b/companion/app/(tabs)/bookings.tsx
deleted file mode 100644
index eae406da50..0000000000
--- a/companion/app/(tabs)/bookings.tsx
+++ /dev/null
@@ -1,1532 +0,0 @@
-import { Ionicons } from "@expo/vector-icons";
-import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
-import SegmentedControl from "@react-native-segmented-control/segmented-control";
-import { useRouter } from "expo-router";
-import React, { useState, useEffect, useMemo } from "react";
-import {
- View,
- Text,
- FlatList,
- ActivityIndicator,
- Alert,
- TouchableOpacity,
- RefreshControl,
- Platform,
- TextInput,
- ActionSheetIOS,
- Linking,
- Modal,
- ScrollView,
-} from "react-native";
-import type { NativeSyntheticEvent } from "react-native";
-
-import { CalComAPIService, Booking, EventType } from "../../services/calcom";
-import { Header } from "../../components/Header";
-import { FullScreenModal } from "../../components/FullScreenModal";
-import { LoadingSpinner } from "../../components/LoadingSpinner";
-import { BookingActionsModal } from "../../components/BookingActionsModal";
-import { EmptyScreen } from "../../components/EmptyScreen";
-import { SvgImage } from "../../components/SvgImage";
-import { getAppIconUrl } from "../../utils/getAppIconUrl";
-import { useAuth } from "../../contexts/AuthContext";
-import {
- useBookings,
- useCancelBooking,
- useConfirmBooking,
- useDeclineBooking,
- useRescheduleBooking,
-} from "../../hooks";
-import { showErrorAlert } from "../../utils/alerts";
-import { offlineAwareRefresh } from "../../utils/network";
-import { openInAppBrowser } from "../../utils/browser";
-
-type BookingFilter = "upcoming" | "unconfirmed" | "past" | "cancelled";
-
-// Helper to extract clean meeting URL from potentially wrapped URLs
-const extractMeetingUrl = (location: string): string => {
- // Check if it's a goo.gl redirect URL with embedded meet.google.com link
- if (location.includes("goo.gl") && location.includes("meet.google.com")) {
- // Extract the actual meet.google.com URL from the redirect
- const meetMatch = location.match(/meet\.google\.com\/[a-z]+-[a-z]+-[a-z]+/i);
- if (meetMatch) {
- return `https://${meetMatch[0]}`;
- }
- }
- return location;
-};
-
-// Helper to detect meeting type from location URL and get icon/label
-const getMeetingInfo = (
- location?: string
-): { appId: string; label: string; iconUrl: string | null; cleanUrl: string } | null => {
- if (!location) return null;
-
- // Check if it's a URL
- if (!location.match(/^https?:\/\//)) return null;
-
- const lowerLocation = location.toLowerCase();
- const cleanUrl = extractMeetingUrl(location);
-
- // Cal Video
- if (lowerLocation.includes("cal.com/video") || lowerLocation.includes("cal.video")) {
- return {
- appId: "cal-video",
- label: "Join Cal Video",
- iconUrl: getAppIconUrl("daily_video", "cal-video"),
- cleanUrl,
- };
- }
-
- // Google Meet (including goo.gl redirect URLs)
- if (
- lowerLocation.includes("meet.google.com") ||
- (lowerLocation.includes("goo.gl") && lowerLocation.includes("meet"))
- ) {
- return {
- appId: "google-meet",
- label: "Join Google Meet",
- iconUrl: getAppIconUrl("google_video", "google-meet"),
- cleanUrl,
- };
- }
-
- // Zoom
- if (lowerLocation.includes("zoom.us") || lowerLocation.includes("zoom.com")) {
- return {
- appId: "zoom",
- label: "Join Zoom",
- iconUrl: getAppIconUrl("zoom_video", "zoom"),
- cleanUrl,
- };
- }
-
- // Microsoft Teams
- if (lowerLocation.includes("teams.microsoft.com") || lowerLocation.includes("teams.live.com")) {
- return {
- appId: "msteams",
- label: "Join Microsoft Teams",
- iconUrl: getAppIconUrl("office365_video", "msteams"),
- cleanUrl,
- };
- }
-
- // Webex
- if (lowerLocation.includes("webex.com")) {
- return {
- appId: "webex",
- label: "Join Webex",
- iconUrl: getAppIconUrl("webex_video", "webex"),
- cleanUrl,
- };
- }
-
- // Jitsi
- if (lowerLocation.includes("meet.jit.si") || lowerLocation.includes("jitsi")) {
- return {
- appId: "jitsi",
- label: "Join Jitsi",
- iconUrl: getAppIconUrl("jitsi_video", "jitsi"),
- cleanUrl,
- };
- }
-
- // Not a recognized conferencing app
- return null;
-};
-
-export default function Bookings() {
- const router = useRouter();
- const { userInfo } = useAuth();
- const [searchQuery, setSearchQuery] = useState("");
- const [activeFilter, setActiveFilter] = useState("upcoming");
- const [showFilterModal, setShowFilterModal] = useState(false);
- const [eventTypes, setEventTypes] = useState([]);
- const [selectedEventTypeId, setSelectedEventTypeId] = useState(null);
- const [selectedEventTypeLabel, setSelectedEventTypeLabel] = useState(null);
- const [eventTypesLoading, setEventTypesLoading] = useState(false);
- const [showBookingActionsModal, setShowBookingActionsModal] = useState(false);
- const [selectedBooking, setSelectedBooking] = useState(null);
- const [showRescheduleModal, setShowRescheduleModal] = useState(false);
- const [rescheduleBooking, setRescheduleBooking] = useState(null);
- const [rescheduleDate, setRescheduleDate] = useState("");
- const [rescheduleTime, setRescheduleTime] = useState("");
- const [rescheduleReason, setRescheduleReason] = useState("");
- const [showRejectModal, setShowRejectModal] = useState(false);
- const [rejectBooking, setRejectBooking] = useState(null);
- const [rejectReason, setRejectReason] = useState("");
-
- const filterOptions: { key: BookingFilter; label: string }[] = [
- { key: "upcoming", label: "Upcoming" },
- { key: "unconfirmed", label: "Unconfirmed" },
- { key: "past", label: "Past" },
- { key: "cancelled", label: "Cancelled" },
- ];
-
- const filterLabels = filterOptions.map((option) => option.label);
- const activeIndex = filterOptions.findIndex((option) => option.key === activeFilter);
-
- // Get filters for the active tab
- const getFiltersForActiveTab = () => {
- switch (activeFilter) {
- case "upcoming":
- return { status: ["upcoming"], limit: 50 };
- case "unconfirmed":
- return { status: ["unconfirmed"], limit: 50 };
- case "past":
- return { status: ["past"], limit: 100 };
- case "cancelled":
- return { status: ["cancelled"], limit: 100 };
- default:
- return { status: ["upcoming"], limit: 50 };
- }
- };
-
- // Use React Query hook for fetching bookings
- const {
- data: rawBookings = [],
- isLoading: loading,
- isFetching,
- error: queryError,
- refetch,
- } = useBookings(getFiltersForActiveTab());
-
- // Show refresh indicator when fetching
- const refreshing = isFetching && !loading;
-
- // Cancel booking mutation
- const { mutate: cancelBookingMutation } = useCancelBooking();
-
- // Confirm booking mutation
- const { mutate: confirmBookingMutation, isPending: isConfirming } = useConfirmBooking();
-
- // Decline booking mutation
- const { mutate: declineBookingMutation, isPending: isDeclining } = useDeclineBooking();
-
- // Reschedule booking mutation
- const { mutate: rescheduleBookingMutation, isPending: isRescheduling } = useRescheduleBooking();
-
- // Sort bookings based on active filter
- const bookings = useMemo(() => {
- if (!rawBookings || !Array.isArray(rawBookings)) return [];
-
- const sorted = [...rawBookings];
- switch (activeFilter) {
- case "upcoming":
- case "unconfirmed":
- // Sort by start time ascending
- return sorted.sort(
- (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
- );
- case "past":
- case "cancelled":
- // Sort by start time descending (latest first)
- return sorted.sort(
- (a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()
- );
- default:
- return sorted;
- }
- }, [rawBookings, activeFilter]);
-
- // 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 bookings." : null;
-
- // Clear search and event type filter when status filter changes
- useEffect(() => {
- setSearchQuery("");
- setSelectedEventTypeId(null);
- setSelectedEventTypeLabel(null);
- }, [activeFilter]);
-
- // Handle pull-to-refresh (offline-aware)
- const onRefresh = () => offlineAwareRefresh(refetch);
-
- // Apply local filters (search and event type) using useMemo
- const filteredBookings = useMemo(() => {
- let filtered = bookings;
-
- // Apply event type filter
- if (selectedEventTypeId !== null) {
- filtered = filtered.filter((booking) => booking.eventTypeId === selectedEventTypeId);
- }
-
- // Apply search filter
- if (searchQuery.trim() !== "") {
- const searchLower = searchQuery.toLowerCase();
- filtered = filtered.filter(
- (booking) =>
- // Search in booking title
- booking.title?.toLowerCase().includes(searchLower) ||
- // Search in booking description
- booking.description?.toLowerCase().includes(searchLower) ||
- // Search in event type title
- booking.eventType?.title?.toLowerCase().includes(searchLower) ||
- // Search in attendee names
- (booking.attendees &&
- booking.attendees.some((attendee) =>
- attendee.name?.toLowerCase().includes(searchLower)
- )) ||
- // Search in attendee emails
- (booking.attendees &&
- booking.attendees.some((attendee) =>
- attendee.email?.toLowerCase().includes(searchLower)
- )) ||
- // Search in location
- booking.location?.toLowerCase().includes(searchLower) ||
- // Search in user name
- booking.user?.name?.toLowerCase().includes(searchLower) ||
- // Search in user email
- booking.user?.email?.toLowerCase().includes(searchLower)
- );
- }
-
- return filtered;
- }, [bookings, searchQuery, selectedEventTypeId]);
-
- const handleSearch = (query: string) => {
- setSearchQuery(query);
- };
-
- const fetchEventTypes = async () => {
- try {
- setEventTypesLoading(true);
- const types = await CalComAPIService.getEventTypes();
- setEventTypes(types);
- } catch (err) {
- console.error("Error fetching event types:", err);
- // Error is logged but not displayed to user for event type filter
- } finally {
- setEventTypesLoading(false);
- }
- };
-
- const handleFilterButtonPress = () => {
- setShowFilterModal(true);
- if (eventTypes.length === 0) {
- fetchEventTypes();
- }
- };
-
- const clearEventTypeFilter = () => {
- setSelectedEventTypeId(null);
- setSelectedEventTypeLabel(null);
- };
-
- const handleEventTypeSelect = (eventTypeId: number | null, label?: string | null) => {
- if (eventTypeId === null) {
- clearEventTypeFilter();
- } else {
- setSelectedEventTypeId(eventTypeId);
- setSelectedEventTypeLabel(label || null);
- }
- setShowFilterModal(false);
- };
-
- const handleFilterChange = (filter: BookingFilter) => {
- setActiveFilter(filter);
- };
-
- const handleSegmentChange = (event: NativeSyntheticEvent<{ selectedSegmentIndex: number }>) => {
- const { selectedSegmentIndex } = event.nativeEvent;
- const selectedFilter = filterOptions[selectedSegmentIndex];
- if (selectedFilter) {
- handleFilterChange(selectedFilter.key);
- }
- };
-
- const getEmptyStateContent = () => {
- switch (activeFilter) {
- case "upcoming":
- return {
- icon: "calendar-outline" as const,
- title: "No upcoming bookings",
- text: "As soon as someone books a time with you it will show up here.",
- };
- case "unconfirmed":
- return {
- icon: "calendar-outline" as const,
- title: "No unconfirmed bookings",
- text: "Your unconfirmed bookings will show up here.",
- };
- case "past":
- return {
- icon: "calendar-outline" as const,
- title: "No past bookings",
- text: "Your past bookings will show up here.",
- };
- case "cancelled":
- return {
- icon: "calendar-outline" as const,
- title: "No cancelled bookings",
- text: "Your canceled bookings will show up here.",
- };
- default:
- return {
- icon: "calendar-outline" as const,
- title: "No bookings found",
- text: "Your bookings will appear here.",
- };
- }
- };
-
- const supportsLiquidGlass = isLiquidGlassAvailable();
-
- const renderSegmentedControl = () => {
- const segmentedControlContent = (
-
- );
-
- return (
- <>
- {supportsLiquidGlass ? (
-
- {segmentedControlContent}
-
- ) : (
-
- {segmentedControlContent}
-
- )}
-
-
-
-
-
- Filter
-
-
-
-
-
-
- {selectedEventTypeId !== null ? (
-
-
- Filtered by {selectedEventTypeLabel || "event type"}
-
-
- Clear filter
-
-
- ) : null}
-
- >
- );
- };
-
- const handleBookingPress = (booking: Booking) => {
- router.push({
- pathname: "/booking-detail",
- params: { uid: booking.uid },
- });
- };
-
- const handleBookingPressOld = (booking: Booking) => {
- if (Platform.OS !== "ios") {
- // Fallback for non-iOS platforms
- const attendeesList = booking.attendees?.map((att) => att.name).join(", ") || "No attendees";
- const startTime = booking.start || booking.startTime || "";
- const endTime = booking.end || booking.endTime || "";
-
- const actions = getBookingActions(booking);
- const alertActions = actions.map((action) => ({
- text: action.title,
- style: (action.destructive ? "destructive" : "default") as
- | "destructive"
- | "default"
- | "cancel",
- onPress: action.onPress,
- }));
- alertActions.unshift({ text: "Cancel", style: "cancel" as const, onPress: () => {} });
-
- Alert.alert(
- booking.title,
- `${booking.description ? `${booking.description}\n\n` : ""}Time: ${formatDateTime(
- startTime
- )} - ${formatTime(endTime)}\nAttendees: ${attendeesList}\nStatus: ${booking.status}${
- booking.location ? `\nLocation: ${booking.location}` : ""
- }`,
- alertActions
- );
- return;
- }
-
- const actions = getBookingActions(booking);
- const options = ["Cancel", ...actions.map((action) => action.title)];
- const destructiveButtonIndex = actions.findIndex((action) => action.destructive);
-
- ActionSheetIOS.showActionSheetWithOptions(
- {
- options,
- destructiveButtonIndex:
- destructiveButtonIndex >= 0 ? destructiveButtonIndex + 1 : undefined,
- cancelButtonIndex: 0,
- title: booking.title,
- message: getBookingDetailsMessage(booking),
- },
- (buttonIndex) => {
- if (buttonIndex === 0) return; // Cancel
-
- const actionIndex = buttonIndex - 1;
- if (actions[actionIndex]) {
- actions[actionIndex].onPress();
- }
- }
- );
- };
-
- const getBookingDetailsMessage = (booking: Booking): string => {
- const attendeesList = booking.attendees?.map((att) => att.name).join(", ") || "No attendees";
- const startTime = booking.start || booking.startTime || "";
- const endTime = booking.end || booking.endTime || "";
-
- return `${booking.description ? `${booking.description}\n\n` : ""}Time: ${formatDateTime(
- startTime
- )} - ${formatTime(endTime)}\nAttendees: ${attendeesList}\nStatus: ${formatStatusText(booking.status)}${
- booking.location ? `\nLocation: ${booking.location}` : ""
- }`;
- };
-
- const getBookingActions = (booking: Booking) => {
- const baseActions = [
- {
- title: "Open Location",
- onPress: () => handleOpenLocation(booking),
- destructive: false,
- },
- ];
-
- const specificActions = (() => {
- switch (activeFilter) {
- case "upcoming":
- return [
- {
- title: "Request Reschedule",
- onPress: () => handleRescheduleBooking(booking),
- destructive: false,
- },
- {
- title: "Cancel event",
- onPress: () => handleCancelEvent(booking),
- destructive: true,
- },
- ];
- case "unconfirmed":
- return [
- {
- title: "Confirm booking",
- onPress: () => handleConfirmBooking(booking),
- destructive: false,
- },
- {
- title: "Decline booking",
- onPress: () => handleRejectBooking(booking),
- destructive: true,
- },
- ];
- case "past":
- return [];
- case "cancelled":
- return [];
- default:
- return [];
- }
- })();
-
- return [...baseActions, ...specificActions];
- };
-
- const handleOpenLocation = async (booking: Booking) => {
- if (!booking.location) {
- Alert.alert("No Location", "This booking doesn't have a location set.");
- return;
- }
-
- try {
- // Check if location is a URL (starts with http:// or https://)
- if (booking.location.match(/^https?:\/\//)) {
- // Open web URLs in in-app browser
- await openInAppBrowser(booking.location, "meeting link");
- } else {
- // If it's not a URL, try to open it as a location in maps
- const mapsUrl =
- Platform.OS === "ios"
- ? `maps://maps.apple.com/?q=${encodeURIComponent(booking.location)}`
- : `geo:0,0?q=${encodeURIComponent(booking.location)}`;
-
- const supported = await Linking.canOpenURL(mapsUrl);
- if (supported) {
- await Linking.openURL(mapsUrl);
- } else {
- // Fallback to Google Maps in in-app browser
- const googleMapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(
- booking.location
- )}`;
- await openInAppBrowser(googleMapsUrl, "Google Maps");
- }
- }
- } catch (error) {
- showErrorAlert("Error", "Failed to open location. Please try again.");
- }
- };
-
- const handleRescheduleBooking = (booking: Booking) => {
- // Pre-fill with the current booking date/time
- const currentDate = new Date(booking.startTime);
- const dateStr = currentDate.toISOString().split("T")[0]; // YYYY-MM-DD
- const timeStr = currentDate.toTimeString().slice(0, 5); // HH:MM
-
- setRescheduleBooking(booking);
- setRescheduleDate(dateStr);
- setRescheduleTime(timeStr);
- setRescheduleReason("");
- setShowRescheduleModal(true);
- };
-
- const handleSubmitReschedule = () => {
- if (!rescheduleBooking || !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();
-
- rescheduleBookingMutation(
- {
- uid: rescheduleBooking.uid,
- start: startUtc,
- reschedulingReason: rescheduleReason || undefined,
- },
- {
- onSuccess: () => {
- setShowRescheduleModal(false);
- setRescheduleBooking(null);
- Alert.alert("Success", "Booking rescheduled successfully");
- },
- onError: (error) => {
- showErrorAlert("Error", error.message || "Failed to reschedule booking");
- },
- }
- );
- };
-
- const handleCancelEvent = (booking: Booking) => {
- Alert.alert("Cancel Event", `Are you sure you want to cancel "${booking.title}"?`, [
- { text: "Cancel", style: "cancel" },
- {
- text: "Cancel Event",
- style: "destructive",
- onPress: () => {
- // Prompt for cancellation reason
- Alert.prompt(
- "Cancellation Reason",
- "Please provide a reason for cancelling this booking:",
- [
- { text: "Cancel", style: "cancel" },
- {
- text: "Cancel Event",
- style: "destructive",
- onPress: (reason) => {
- const cancellationReason = reason?.trim() || "Event cancelled by host";
- cancelBookingMutation(
- { uid: booking.uid, reason: cancellationReason },
- {
- onSuccess: () => {
- Alert.alert("Success", "Event cancelled successfully");
- },
- onError: (error) => {
- console.error("Failed to cancel booking:", error);
- showErrorAlert("Error", "Failed to cancel event. Please try again.");
- },
- }
- );
- },
- },
- ],
- "plain-text",
- "",
- "default"
- );
- },
- },
- ]);
- };
-
- const handleConfirmBooking = (booking: Booking) => {
- Alert.alert("Confirm Booking", `Are you sure you want to confirm "${booking.title}"?`, [
- { text: "Cancel", style: "cancel" },
- {
- text: "Confirm",
- onPress: () => {
- confirmBookingMutation(
- { uid: booking.uid },
- {
- onSuccess: () => {
- Alert.alert("Success", "Booking confirmed successfully");
- },
- onError: (error) => {
- showErrorAlert("Error", error.message || "Failed to confirm booking");
- },
- }
- );
- },
- },
- ]);
- };
-
- const handleRejectBooking = (booking: Booking) => {
- Alert.alert("Decline Booking", `Are you sure you want to decline "${booking.title}"?`, [
- { text: "Cancel", style: "cancel" },
- {
- text: "Decline",
- style: "destructive",
- onPress: () => {
- // Show optional reason input
- Alert.prompt(
- "Decline Reason",
- "Optionally provide a reason for declining (press OK to skip)",
- [
- { text: "Cancel", style: "cancel" },
- {
- text: "OK",
- onPress: (reason?: string) => {
- declineBookingMutation(
- { uid: booking.uid, reason: reason || undefined },
- {
- onSuccess: () => {
- Alert.alert("Success", "Booking declined successfully");
- },
- onError: (error) => {
- showErrorAlert("Error", error.message || "Failed to decline booking");
- },
- }
- );
- },
- },
- ],
- "plain-text",
- "",
- "default"
- );
- },
- },
- ]);
- };
-
- const formatDateTime = (dateString: string) => {
- const date = new Date(dateString);
- return date.toLocaleDateString("en-US", {
- weekday: "short",
- month: "short",
- day: "numeric",
- hour: "numeric",
- minute: "2-digit",
- });
- };
-
- const formatTime = (dateString: string) => {
- if (!dateString) {
- return "";
- }
- try {
- const date = new Date(dateString);
- if (isNaN(date.getTime())) {
- console.warn("Invalid date string:", dateString);
- return "";
- }
- return date.toLocaleTimeString("en-US", {
- hour: "numeric",
- minute: "2-digit",
- hour12: true,
- });
- } catch (error) {
- console.error("Error formatting time:", error, dateString);
- return "";
- }
- };
-
- const formatDate = (dateString: string, isUpcoming: boolean) => {
- if (!dateString) {
- return "";
- }
- try {
- const date = new Date(dateString);
- if (isNaN(date.getTime())) {
- console.warn("Invalid date string:", dateString);
- return "";
- }
- const bookingYear = date.getFullYear();
- const currentYear = new Date().getFullYear();
- const isDifferentYear = bookingYear !== currentYear;
-
- if (isUpcoming) {
- // Format: "ddd, D MMM" or "ddd, D MMM YYYY" if different year
- const weekday = date.toLocaleDateString("en-US", { weekday: "short" });
- const day = date.getDate();
- const month = date.toLocaleDateString("en-US", { month: "short" });
- if (isDifferentYear) {
- return `${weekday}, ${day} ${month} ${bookingYear}`;
- }
- return `${weekday}, ${day} ${month}`;
- } else {
- // Format: "D MMMM YYYY" for past bookings
- return date.toLocaleDateString("en-US", {
- day: "numeric",
- month: "long",
- year: "numeric",
- });
- }
- } catch (error) {
- console.error("Error formatting date:", error, dateString);
- return "";
- }
- };
-
- const getStatusColor = (status: string) => {
- // API v2 2024-08-13 returns status in lowercase, so normalize to uppercase for comparison
- const normalizedStatus = status.toUpperCase();
- switch (normalizedStatus) {
- case "ACCEPTED":
- return "#34C759";
- case "PENDING":
- return "#FF9500";
- case "CANCELLED":
- return "#FF3B30";
- case "REJECTED":
- return "#FF3B30";
- default:
- return "#666";
- }
- };
-
- const formatStatusText = (status: string) => {
- // Capitalize first letter for display
- return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
- };
-
- const formatMonthYear = (dateString: string): string => {
- if (!dateString) return "";
- try {
- const date = new Date(dateString);
- if (isNaN(date.getTime())) {
- return "";
- }
- const now = new Date();
- const isCurrentMonth =
- date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth();
-
- if (isCurrentMonth) {
- return "This month";
- }
-
- return date.toLocaleDateString("en-US", {
- month: "long",
- year: "numeric",
- });
- } catch (error) {
- return "";
- }
- };
-
- const getMonthYearKey = (dateString: string): string => {
- if (!dateString) return "";
- try {
- const date = new Date(dateString);
- if (isNaN(date.getTime())) {
- return "";
- }
- return `${date.getFullYear()}-${date.getMonth()}`;
- } catch (error) {
- return "";
- }
- };
-
- type ListItem =
- | { type: "monthHeader"; monthYear: string; key: string }
- | { type: "booking"; booking: Booking; key: string };
-
- const groupBookingsByMonth = (bookings: Booking[]): ListItem[] => {
- const grouped: ListItem[] = [];
- let currentMonthYear: string | null = null;
-
- bookings.forEach((booking) => {
- const startTime = booking.start || booking.startTime || "";
- if (!startTime) return;
-
- const monthYearKey = getMonthYearKey(startTime);
- const monthYear = formatMonthYear(startTime);
-
- if (monthYearKey !== currentMonthYear) {
- currentMonthYear = monthYearKey;
- grouped.push({
- type: "monthHeader",
- monthYear,
- key: `month-${monthYearKey}`,
- });
- }
-
- grouped.push({
- type: "booking",
- booking,
- key: booking.id.toString(),
- });
- });
-
- return grouped;
- };
-
- const renderBooking = ({ item }: { item: Booking }) => {
- const startTime = item.start || item.startTime || "";
- const endTime = item.end || item.endTime || "";
- const isUpcoming = new Date(endTime) >= new Date();
- const isPending = item.status?.toUpperCase() === "PENDING";
- const isCancelled = item.status?.toUpperCase() === "CANCELLED";
- const isRejected = item.status?.toUpperCase() === "REJECTED";
-
- const getHostAndAttendeesDisplay = () => {
- const hasHostOrAttendees =
- (item.hosts && item.hosts.length > 0) ||
- item.user ||
- (item.attendees && item.attendees.length > 0);
-
- if (!hasHostOrAttendees) return null;
-
- const currentUserEmail = userInfo?.email?.toLowerCase();
- const hostEmail = item.hosts?.[0]?.email?.toLowerCase() || item.user?.email?.toLowerCase();
- const isCurrentUserHost = currentUserEmail && hostEmail && currentUserEmail === hostEmail;
-
- const hostName = isCurrentUserHost
- ? "You"
- : item.hosts?.[0]?.name || item.hosts?.[0]?.email || item.user?.name || item.user?.email;
-
- const attendeesDisplay =
- item.attendees && item.attendees.length > 0
- ? item.attendees.length === 1
- ? item.attendees[0].name || item.attendees[0].email
- : item.attendees
- .slice(0, 2)
- .map((att) => att.name || att.email)
- .join(", ") +
- (item.attendees.length > 2 ? ` and ${item.attendees.length - 2} more` : "")
- : null;
-
- if (hostName && attendeesDisplay) {
- return `${hostName} and ${attendeesDisplay}`;
- } else if (hostName) {
- return hostName;
- } else if (attendeesDisplay) {
- return attendeesDisplay;
- }
- return null;
- };
-
- const hostAndAttendeesDisplay = getHostAndAttendeesDisplay();
- const meetingInfo = getMeetingInfo(item.location);
-
- return (
-
- handleBookingPress(item)}
- onLongPress={() => {
- setSelectedBooking(item);
- setShowBookingActionsModal(true);
- }}
- style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}
- >
- {/* Time and Date Row */}
-
-
- {formatDate(startTime, isUpcoming)}
-
-
- {formatTime(startTime)} - {formatTime(endTime)}
-
-
- {/* Badges Row */}
-
- {isPending ? (
-
- Unconfirmed
-
- ) : null}
-
- {/* Title */}
-
- {item.title}
-
- {/* Description */}
- {item.description ? (
-
- "{item.description}"
-
- ) : null}
- {/* Host and Attendees */}
- {hostAndAttendeesDisplay ? (
- {hostAndAttendeesDisplay}
- ) : null}
- {/* Meeting Link */}
- {meetingInfo ? (
-
- {
- e.stopPropagation();
- try {
- await Linking.openURL(meetingInfo.cleanUrl);
- } catch {
- showErrorAlert("Error", "Failed to open meeting link. Please try again.");
- }
- }}
- >
- {meetingInfo.iconUrl ? (
-
- ) : (
-
- )}
- {meetingInfo.label}
-
-
- ) : null}
-
-
- {isPending ? (
- <>
- {
- e.stopPropagation();
- confirmBookingMutation(
- { uid: item.uid },
- {
- onSuccess: () => {
- Alert.alert("Success", "Booking confirmed successfully");
- },
- onError: (error) => {
- showErrorAlert("Error", "Failed to confirm booking. Please try again.");
- },
- }
- );
- }}
- >
-
- Confirm
-
- {
- e.stopPropagation();
- setRejectBooking(item);
- setRejectReason("");
- setShowRejectModal(true);
- }}
- >
-
- Reject
-
- >
- ) : null}
- {
- e.stopPropagation();
- setSelectedBooking(item);
- setShowBookingActionsModal(true);
- }}
- >
-
-
-
-
- );
- };
-
- const renderListItem = ({ item }: { item: ListItem }) => {
- if (item.type === "monthHeader") {
- return (
-
- {item.monthYear}
-
- );
- }
- return renderBooking({ item: item.booking });
- };
-
- if (loading) {
- return (
-
-
- {renderSegmentedControl()}
-
-
-
-
- );
- }
-
- if (error) {
- return (
-
-
- {renderSegmentedControl()}
-
-
-
- Unable to load bookings
-
- {error}
- refetch()}>
- Retry
-
-
-
- );
- }
-
- // Determine what content to show
- const showEmptyState = bookings.length === 0 && !loading;
- const showSearchEmptyState =
- filteredBookings.length === 0 && searchQuery.trim() !== "" && !loading && !showEmptyState;
- const showList = !showEmptyState && !showSearchEmptyState && !loading;
- const emptyState = getEmptyStateContent();
-
- return (
-
-
- {renderSegmentedControl()}
-
- {/* Empty state - no bookings */}
- {showEmptyState ? (
-
- }
- >
-
-
-
- ) : null}
-
- {/* Search empty state */}
- {showSearchEmptyState ? (
-
- }
- >
-
-
-
- ) : null}
-
- {/* Bookings list */}
- {showList ? (
-
-
- item.key}
- renderItem={renderListItem}
- contentContainerStyle={{ paddingBottom: 90 }}
- refreshControl={}
- showsVerticalScrollIndicator={false}
- />
-
-
- ) : null}
-
- {/* Filter Modal */}
- setShowFilterModal(false)}
- >
- setShowFilterModal(false)}
- >
- e.stopPropagation()}
- className="w-[85%] max-w-[350px] rounded-2xl bg-white p-5"
- >
-
- Filter by Event Type
-
-
- {eventTypesLoading ? (
-
-
- Loading event types...
-
- ) : (
-
- {eventTypes.map((eventType) => (
- handleEventTypeSelect(eventType.id, eventType.title)}
- >
-
- {eventType.title}
-
- {selectedEventTypeId === eventType.id ? (
-
- ) : null}
-
- ))}
-
- {eventTypes.length === 0 ? (
-
- No event types found
-
- ) : null}
-
- )}
-
-
-
-
- {/* Booking Actions Modal */}
- setShowBookingActionsModal(false)}
- booking={selectedBooking}
- hasLocationUrl={!!selectedBooking?.location}
- isUpcoming={
- selectedBooking
- ? new Date(selectedBooking.endTime || selectedBooking.end || "") >= new Date() &&
- selectedBooking.status?.toUpperCase() !== "PENDING"
- : false
- }
- isPast={
- selectedBooking
- ? new Date(selectedBooking.endTime || selectedBooking.end || "") < new Date()
- : false
- }
- isCancelled={selectedBooking?.status?.toUpperCase() === "CANCELLED"}
- isUnconfirmed={selectedBooking?.status?.toUpperCase() === "PENDING"}
- onReschedule={() => {
- if (selectedBooking) handleRescheduleBooking(selectedBooking);
- }}
- 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={() => {
- if (selectedBooking) handleCancelEvent(selectedBooking);
- }}
- />
-
- {/* Reschedule Modal */}
- {
- setShowRescheduleModal(false);
- setRescheduleBooking(null);
- }}
- >
-
- {rescheduleBooking ? (
- <>
-
- Reschedule "{rescheduleBooking.title}"
-
-
- {/* Date Input */}
-
-
- New Date (YYYY-MM-DD)
-
-
-
-
- {/* Time Input */}
-
-
- New Time (HH:MM, 24-hour format)
-
-
-
-
- {/* Reason Input */}
-
- Reason (optional)
-
-
-
- {/* Submit Button */}
-
- {isRescheduling ? (
-
- ) : (
-
- Reschedule Booking
-
- )}
-
-
- {/* Cancel Button */}
- {
- setShowRescheduleModal(false);
- setRescheduleBooking(null);
- }}
- >
- Cancel
-
- >
- ) : null}
-
-
-
- {/* Reject Booking Modal */}
- {
- setShowRejectModal(false);
- setRejectBooking(null);
- setRejectReason("");
- }}
- >
- {
- setShowRejectModal(false);
- setRejectBooking(null);
- setRejectReason("");
- }}
- >
- e.stopPropagation()}
- >
-
- {/* Title */}
-
- Reject the booking request?
-
-
- {/* Description */}
-
- Are you sure you want to reject the booking? We'll let the person who tried to book
- know. You can provide a reason below.
-
-
- {/* Reason Input */}
-
-
- Reason for rejecting (Optional)
-
-
-
-
- {/* Separator */}
-
-
- {/* Buttons Row */}
-
- {/* Close Button */}
- {
- setShowRejectModal(false);
- setRejectBooking(null);
- setRejectReason("");
- }}
- >
- Close
-
-
- {/* Reject Button */}
- {
- if (rejectBooking) {
- declineBookingMutation(
- { uid: rejectBooking.uid, reason: rejectReason || undefined },
- {
- onSuccess: () => {
- setShowRejectModal(false);
- setRejectBooking(null);
- setRejectReason("");
- Alert.alert("Success", "Booking rejected successfully");
- },
- onError: (error) => {
- showErrorAlert("Error", "Failed to reject booking. Please try again.");
- },
- }
- );
- }
- }}
- disabled={isDeclining}
- style={{ opacity: isDeclining ? 0.5 : 1 }}
- >
- Reject the booking
-
-
-
-
-
-
-
- );
-}
diff --git a/companion/components/booking-list-item/BookingListItem.ios.tsx b/companion/components/booking-list-item/BookingListItem.ios.tsx
new file mode 100644
index 0000000000..09eca44244
--- /dev/null
+++ b/companion/components/booking-list-item/BookingListItem.ios.tsx
@@ -0,0 +1,262 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { View, Text, TouchableOpacity, Linking, Pressable, Alert } from "react-native";
+import { Host, ContextMenu, Button, Image, HStack } from "@expo/ui/swift-ui";
+import { buttonStyle, frame, padding } from "@expo/ui/swift-ui/modifiers";
+import { isLiquidGlassAvailable } from "expo-glass-effect";
+
+import type { BookingListItemProps } from "./types";
+import { SvgImage } from "../SvgImage";
+import { getMeetingInfo } from "../../utils/meetings-utils";
+import { formatTime, formatDate, getHostAndAttendeesDisplay } from "../../utils/bookings-utils";
+import { showErrorAlert } from "../../utils/alerts";
+
+export const BookingListItem: React.FC = ({
+ booking,
+ userEmail,
+ isConfirming,
+ isDeclining,
+ onPress,
+ onLongPress,
+ onConfirm,
+ onReject,
+ onActionsPress,
+ onReschedule,
+ onEditLocation,
+ onAddGuests,
+ onViewRecordings,
+ onMeetingSessionDetails,
+ onMarkNoShow,
+ onReportBooking,
+ onCancelBooking,
+}) => {
+ const startTime = booking.start || booking.startTime || "";
+ const endTime = booking.end || booking.endTime || "";
+ const isUpcoming = new Date(endTime) >= new Date();
+ const isPending = booking.status?.toUpperCase() === "PENDING";
+ const isCancelled = booking.status?.toUpperCase() === "CANCELLED";
+ const isRejected = booking.status?.toUpperCase() === "REJECTED";
+ const hasLocationUrl = !!booking.location;
+
+ const hostAndAttendeesDisplay = getHostAndAttendeesDisplay(booking, userEmail);
+ const meetingInfo = getMeetingInfo(booking.location);
+
+ // Define context menu actions based on booking state
+ type ContextMenuAction = {
+ label: string;
+ icon: string;
+ onPress: () => void;
+ role: "default" | "destructive";
+ };
+
+ const allActions: (ContextMenuAction & { visible: boolean })[] = [
+ // Edit Event Section
+ {
+ label: "Reschedule Booking",
+ icon: "calendar",
+ onPress: () => onReschedule?.(booking),
+ role: "default",
+ visible: isUpcoming && !isCancelled && !isPending && !!onReschedule,
+ },
+ {
+ label: "Edit Location",
+ icon: "location",
+ onPress: () => onEditLocation?.(booking),
+ role: "default",
+ visible: isUpcoming && !isCancelled && !isPending && !!onEditLocation,
+ },
+ {
+ label: "Add Guests",
+ icon: "person.badge.plus",
+ onPress: () => onAddGuests?.(booking),
+ role: "default",
+ visible: isUpcoming && !isCancelled && !isPending && !!onAddGuests,
+ },
+ // After Event Section
+ {
+ label: "View Recordings",
+ icon: "video",
+ onPress: () => onViewRecordings?.(booking),
+ role: "default",
+ visible: hasLocationUrl && !isUpcoming && !isCancelled && !isPending && !!onViewRecordings,
+ },
+ {
+ label: "Meeting Session Details",
+ icon: "info.circle",
+ onPress: () => onMeetingSessionDetails?.(booking),
+ role: "default",
+ visible:
+ hasLocationUrl && !isUpcoming && !isCancelled && !isPending && !!onMeetingSessionDetails,
+ },
+ {
+ label: "Mark as No-Show",
+ icon: "eye.slash",
+ onPress: () => onMarkNoShow?.(booking),
+ role: "default",
+ visible: !isUpcoming && !isPending && !!onMarkNoShow,
+ },
+ // Other Actions
+ {
+ label: "Report Booking",
+ icon: "flag",
+ onPress: () => onReportBooking?.(booking),
+ role: "destructive",
+ visible: !!onReportBooking,
+ },
+ {
+ label: "Cancel Event",
+ icon: "xmark.circle",
+ onPress: () => onCancelBooking?.(booking),
+ role: "destructive",
+ visible: isUpcoming && !isCancelled && !!onCancelBooking,
+ },
+ ];
+
+ const contextMenuActions: ContextMenuAction[] = allActions
+ .filter((action) => action.visible)
+ .map(({ label, icon, onPress, role }) => ({ label, icon, onPress, role }));
+
+ return (
+
+ onPress(booking)}
+ onLongPress={() => onLongPress(booking)}
+ style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}
+ className="active:bg-[#F8F9FA]"
+ >
+ {/* Time and Date Row */}
+
+
+ {formatDate(startTime, isUpcoming)}
+
+
+ {formatTime(startTime)} - {formatTime(endTime)}
+
+
+ {/* Badges Row */}
+
+ {isPending ? (
+
+ Unconfirmed
+
+ ) : null}
+
+ {/* Title */}
+
+ {booking.title}
+
+ {/* Description */}
+ {booking.description ? (
+
+ "{booking.description}"
+
+ ) : null}
+ {/* Host and Attendees */}
+ {hostAndAttendeesDisplay ? (
+ {hostAndAttendeesDisplay}
+ ) : null}
+ {/* Meeting Link */}
+ {meetingInfo ? (
+
+ {
+ e.stopPropagation();
+ try {
+ await Linking.openURL(meetingInfo.cleanUrl);
+ } catch {
+ showErrorAlert("Error", "Failed to open meeting link. Please try again.");
+ }
+ }}
+ >
+ {meetingInfo.iconUrl ? (
+
+ ) : (
+
+ )}
+ {meetingInfo.label}
+
+
+ ) : null}
+
+
+ {isPending ? (
+ <>
+ {
+ e.stopPropagation();
+ onConfirm(booking);
+ }}
+ >
+
+ Confirm
+
+ {
+ e.stopPropagation();
+ onReject(booking);
+ }}
+ >
+
+ Reject
+
+ >
+ ) : null}
+
+ {/* iOS Context Menu */}
+
+
+
+ {contextMenuActions.map((action) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/companion/components/booking-list-item/BookingListItem.tsx b/companion/components/booking-list-item/BookingListItem.tsx
new file mode 100644
index 0000000000..de2de5b568
--- /dev/null
+++ b/companion/components/booking-list-item/BookingListItem.tsx
@@ -0,0 +1,158 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { View, Text, TouchableOpacity, Linking } from "react-native";
+
+import type { BookingListItemProps } from "./types";
+import { SvgImage } from "../SvgImage";
+import { getMeetingInfo } from "../../utils/meetings-utils";
+import { formatTime, formatDate, getHostAndAttendeesDisplay } from "../../utils/bookings-utils";
+import { showErrorAlert } from "../../utils/alerts";
+
+export const BookingListItem: React.FC = ({
+ booking,
+ userEmail,
+ isConfirming,
+ isDeclining,
+ onPress,
+ onLongPress,
+ onConfirm,
+ onReject,
+ onActionsPress,
+}) => {
+ const startTime = booking.start || booking.startTime || "";
+ const endTime = booking.end || booking.endTime || "";
+ const isUpcoming = new Date(endTime) >= new Date();
+ const isPending = booking.status?.toUpperCase() === "PENDING";
+ const isCancelled = booking.status?.toUpperCase() === "CANCELLED";
+ const isRejected = booking.status?.toUpperCase() === "REJECTED";
+
+ const hostAndAttendeesDisplay = getHostAndAttendeesDisplay(booking, userEmail);
+ const meetingInfo = getMeetingInfo(booking.location);
+
+ return (
+
+ onPress(booking)}
+ onLongPress={() => onLongPress(booking)}
+ style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}
+ >
+ {/* Time and Date Row */}
+
+
+ {formatDate(startTime, isUpcoming)}
+
+
+ {formatTime(startTime)} - {formatTime(endTime)}
+
+
+ {/* Badges Row */}
+
+ {isPending ? (
+
+ Unconfirmed
+
+ ) : null}
+
+ {/* Title */}
+
+ {booking.title}
+
+ {/* Description */}
+ {booking.description ? (
+
+ "{booking.description}"
+
+ ) : null}
+ {/* Host and Attendees */}
+ {hostAndAttendeesDisplay ? (
+ {hostAndAttendeesDisplay}
+ ) : null}
+ {/* Meeting Link */}
+ {meetingInfo ? (
+
+ {
+ e.stopPropagation();
+ try {
+ await Linking.openURL(meetingInfo.cleanUrl);
+ } catch {
+ showErrorAlert("Error", "Failed to open meeting link. Please try again.");
+ }
+ }}
+ >
+ {meetingInfo.iconUrl ? (
+
+ ) : (
+
+ )}
+ {meetingInfo.label}
+
+
+ ) : null}
+
+
+ {isPending ? (
+ <>
+ {
+ e.stopPropagation();
+ onConfirm(booking);
+ }}
+ >
+
+ Confirm
+
+ {
+ e.stopPropagation();
+ onReject(booking);
+ }}
+ >
+
+ Reject
+
+ >
+ ) : null}
+ {
+ e.stopPropagation();
+ onActionsPress(booking);
+ }}
+ >
+
+
+
+
+ );
+};
diff --git a/companion/components/booking-list-item/types.ts b/companion/components/booking-list-item/types.ts
new file mode 100644
index 0000000000..a563edc1a0
--- /dev/null
+++ b/companion/components/booking-list-item/types.ts
@@ -0,0 +1,22 @@
+import type { Booking } from "../../services/calcom";
+
+export interface BookingListItemProps {
+ booking: Booking;
+ userEmail?: string;
+ isConfirming: boolean;
+ isDeclining: boolean;
+ onPress: (booking: Booking) => void;
+ onLongPress: (booking: Booking) => void;
+ onConfirm: (booking: Booking) => void;
+ onReject: (booking: Booking) => void;
+ onActionsPress: (booking: Booking) => void;
+ // Additional action handlers for context menu (iOS)
+ onReschedule?: (booking: Booking) => void;
+ onEditLocation?: (booking: Booking) => void;
+ onAddGuests?: (booking: Booking) => void;
+ onViewRecordings?: (booking: Booking) => void;
+ onMeetingSessionDetails?: (booking: Booking) => void;
+ onMarkNoShow?: (booking: Booking) => void;
+ onReportBooking?: (booking: Booking) => void;
+ onCancelBooking?: (booking: Booking) => void;
+}
diff --git a/companion/components/booking-list-screen/BookingListScreen.tsx b/companion/components/booking-list-screen/BookingListScreen.tsx
new file mode 100644
index 0000000000..c937d2069b
--- /dev/null
+++ b/companion/components/booking-list-screen/BookingListScreen.tsx
@@ -0,0 +1,406 @@
+import { Ionicons } from "@expo/vector-icons";
+import { useRouter } from "expo-router";
+import React, { Activity, useMemo, useState } from "react";
+import { View, Text, FlatList, RefreshControl, ScrollView, Alert } from "react-native";
+
+import type { Booking, EventType } from "../../services/calcom";
+import { LoadingSpinner } from "../LoadingSpinner";
+import { EmptyScreen } from "../EmptyScreen";
+import { BookingListItem } from "../booking-list-item/BookingListItem";
+import { BookingModals } from "../booking-modals/BookingModals";
+import { useAuth } from "../../contexts/AuthContext";
+import {
+ useBookings,
+ useCancelBooking,
+ useConfirmBooking,
+ useDeclineBooking,
+ useRescheduleBooking,
+ useBookingActions,
+ type BookingFilter,
+} from "../../hooks";
+import { offlineAwareRefresh } from "../../utils/network";
+import {
+ getEmptyStateContent,
+ groupBookingsByMonth,
+ searchBookings,
+ filterByEventType,
+} from "../../utils/bookings-utils";
+import type { ListItem } from "../../utils/bookings-utils";
+
+interface BookingListScreenProps {
+ // Platform-specific header rendering
+ renderHeader?: () => React.ReactNode;
+
+ // Platform-specific filter controls
+ renderFilterControls?: () => React.ReactNode;
+
+ // Whether to show the filter modal (only for non-iOS)
+ showFilterModal?: boolean;
+ setShowFilterModal?: (show: boolean) => void;
+
+ // Event types for filtering
+ eventTypes?: EventType[];
+ eventTypesLoading?: boolean;
+
+ // Search query (controlled from parent for iOS headerSearchBar)
+ searchQuery: string;
+ onSearchChange?: (query: string) => void;
+
+ // Event type filter (controlled from parent)
+ selectedEventTypeId: number | null;
+ onEventTypeChange?: (id: number | null, label?: string | null) => void;
+
+ // Active filter (controlled from parent)
+ activeFilter: BookingFilter;
+ filterParams: Record;
+
+ // iOS-style list (no wrapper)
+ iosStyle?: boolean;
+}
+
+export const BookingListScreen: React.FC = ({
+ renderHeader,
+ renderFilterControls,
+ showFilterModal,
+ setShowFilterModal,
+ eventTypes,
+ eventTypesLoading,
+ searchQuery,
+ selectedEventTypeId,
+ onEventTypeChange,
+ activeFilter,
+ filterParams,
+ iosStyle = false,
+}) => {
+ const router = useRouter();
+ const { userInfo } = useAuth();
+ const [isManualRefreshing, setIsManualRefreshing] = useState(false);
+
+ // Use React Query hook for fetching bookings
+ const {
+ data: rawBookings = [],
+ isLoading: loading,
+ isFetching,
+ error: queryError,
+ refetch,
+ } = useBookings(filterParams);
+
+ // Show refresh indicator when fetching
+ const refreshing = isFetching && !loading;
+
+ // Cancel booking mutation
+ const { mutate: cancelBookingMutation } = useCancelBooking();
+
+ // Confirm booking mutation
+ const { mutate: confirmBookingMutation, isPending: isConfirming } = useConfirmBooking();
+
+ // Decline booking mutation
+ const { mutate: declineBookingMutation, isPending: isDeclining } = useDeclineBooking();
+
+ // Reschedule booking mutation
+ const { mutate: rescheduleBookingMutation, isPending: isRescheduling } = useRescheduleBooking();
+
+ // Booking actions hook
+ const {
+ showRescheduleModal,
+ rescheduleBooking,
+ rescheduleDate,
+ setRescheduleDate,
+ rescheduleTime,
+ setRescheduleTime,
+ rescheduleReason,
+ setRescheduleReason,
+ showRejectModal,
+ rejectReason,
+ setRejectReason,
+ selectedBooking,
+ setSelectedBooking,
+ handleBookingPress,
+ handleRescheduleBooking,
+ handleSubmitReschedule,
+ handleCloseRescheduleModal,
+ handleCancelBooking,
+ handleInlineConfirm,
+ handleOpenRejectModal,
+ handleSubmitReject,
+ handleCloseRejectModal,
+ } = useBookingActions({
+ router,
+ cancelMutation: cancelBookingMutation,
+ confirmMutation: confirmBookingMutation,
+ declineMutation: declineBookingMutation,
+ rescheduleMutation: rescheduleBookingMutation,
+ isConfirming,
+ isDeclining,
+ isRescheduling,
+ });
+
+ // Sort bookings based on active filter
+ const bookings = useMemo(() => {
+ if (!rawBookings || !Array.isArray(rawBookings)) return [];
+
+ const sorted = [...rawBookings];
+ switch (activeFilter) {
+ case "upcoming":
+ case "unconfirmed":
+ // Sort by start time ascending
+ return sorted.sort(
+ (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
+ );
+ case "past":
+ case "cancelled":
+ // Sort by start time descending (latest first)
+ return sorted.sort(
+ (a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()
+ );
+ default:
+ return sorted;
+ }
+ }, [rawBookings, activeFilter]);
+
+ // 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 bookings." : null;
+
+ // Prevents showing loading indicator when refreshing or changing filters
+ function manualRefresh() {
+ setIsManualRefreshing(true);
+ offlineAwareRefresh(refetch).finally(() => {
+ setIsManualRefreshing(false);
+ });
+ }
+
+ // Apply local filters (search and event type) using useMemo
+ const filteredBookings = useMemo(() => {
+ let filtered = bookings;
+
+ // Apply event type filter
+ filtered = filterByEventType(filtered, selectedEventTypeId);
+
+ // Apply search filter
+ filtered = searchBookings(filtered, searchQuery);
+
+ return filtered;
+ }, [bookings, searchQuery, selectedEventTypeId]);
+
+ const [showBookingActionsModal, setShowBookingActionsModal] = React.useState(false);
+
+ const renderBookingItem = ({ item }: { item: Booking }) => {
+ return (
+ {
+ setSelectedBooking(booking);
+ setShowBookingActionsModal(true);
+ }}
+ onConfirm={handleInlineConfirm}
+ onReject={handleOpenRejectModal}
+ onActionsPress={(booking) => {
+ setSelectedBooking(booking);
+ setShowBookingActionsModal(true);
+ }}
+ // iOS context menu action handlers
+ onReschedule={handleRescheduleBooking}
+ 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={handleCancelBooking}
+ />
+ );
+ };
+
+ const renderListItem = ({ item }: { item: ListItem }) => {
+ if (item.type === "monthHeader") {
+ return (
+
+ {item.monthYear}
+
+ );
+ }
+ return renderBookingItem({ item: item.booking });
+ };
+
+ if (loading) {
+ return (
+
+ {renderHeader?.()}
+ {renderFilterControls?.()}
+
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {renderHeader?.()}
+ {renderFilterControls?.()}
+
+
+
+ Unable to load bookings
+
+ {error}
+
+
+ );
+ }
+
+ // Determine what content to show
+ const showEmptyState = bookings.length === 0 && !loading;
+ const showSearchEmptyState =
+ filteredBookings.length === 0 && searchQuery.trim() !== "" && !loading && !showEmptyState;
+ const showList = !showEmptyState && !showSearchEmptyState && !loading;
+ const emptyState = getEmptyStateContent(activeFilter);
+
+ return (
+ <>
+ {renderHeader?.()}
+ {renderFilterControls?.()}
+
+ {/* Empty state - no bookings */}
+
+
+
+ }
+ contentInsetAdjustmentBehavior="automatic"
+ style={{ backgroundColor: "white" }}
+ >
+
+
+
+
+
+ {/* Search empty state */}
+
+
+
+ }
+ contentInsetAdjustmentBehavior="automatic"
+ style={{ backgroundColor: "white" }}
+ >
+
+
+
+
+
+ {/* Bookings list */}
+
+
+ item.key}
+ renderItem={renderListItem}
+ contentContainerStyle={{ paddingBottom: 90 }}
+ refreshControl={
+
+ }
+ showsVerticalScrollIndicator={false}
+ contentInsetAdjustmentBehavior="automatic"
+ style={{ backgroundColor: "white" }}
+ />
+
+
+
+
+
+ item.key}
+ renderItem={renderListItem}
+ contentContainerStyle={{ paddingBottom: 90 }}
+ refreshControl={
+
+ }
+ showsVerticalScrollIndicator={false}
+ />
+
+
+
+
+
+ {/* Modals */}
+ setShowFilterModal?.(false)}
+ onEventTypeSelect={onEventTypeChange}
+ showBookingActionsModal={showBookingActionsModal}
+ selectedBooking={selectedBooking}
+ onActionsClose={() => setShowBookingActionsModal(false)}
+ onReschedule={() => {
+ if (selectedBooking) handleRescheduleBooking(selectedBooking);
+ }}
+ onCancel={() => {
+ if (selectedBooking) handleCancelBooking(selectedBooking);
+ }}
+ />
+ >
+ );
+};
diff --git a/companion/components/booking-modals/BookingModals.tsx b/companion/components/booking-modals/BookingModals.tsx
new file mode 100644
index 0000000000..9c66c67666
--- /dev/null
+++ b/companion/components/booking-modals/BookingModals.tsx
@@ -0,0 +1,347 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import {
+ View,
+ Text,
+ TouchableOpacity,
+ TextInput,
+ ActivityIndicator,
+ ScrollView,
+ Alert,
+} from "react-native";
+
+import type { Booking, EventType } from "../../services/calcom";
+import { FullScreenModal } from "../FullScreenModal";
+import { BookingActionsModal } from "../BookingActionsModal";
+
+interface BookingModalsProps {
+ // Reschedule modal props
+ showRescheduleModal: boolean;
+ rescheduleBooking: Booking | null;
+ rescheduleDate: string;
+ rescheduleTime: string;
+ rescheduleReason: string;
+ isRescheduling: boolean;
+ onRescheduleClose: () => void;
+ onRescheduleSubmit: () => void;
+ onRescheduleDateChange: (date: string) => void;
+ onRescheduleTimeChange: (time: string) => void;
+ onRescheduleReasonChange: (reason: string) => void;
+
+ // Reject modal props
+ showRejectModal: boolean;
+ rejectReason: string;
+ isDeclining: boolean;
+ onRejectClose: () => void;
+ onRejectSubmit: () => void;
+ onRejectReasonChange: (reason: string) => void;
+
+ // Filter modal props (optional for iOS)
+ showFilterModal?: boolean;
+ eventTypes?: EventType[];
+ eventTypesLoading?: boolean;
+ selectedEventTypeId?: number | null;
+ onFilterClose?: () => void;
+ onEventTypeSelect?: (id: number | null, label?: string | null) => void;
+
+ // Booking actions modal props
+ showBookingActionsModal: boolean;
+ selectedBooking: Booking | null;
+ onActionsClose: () => void;
+ onReschedule: () => void;
+ onCancel: () => void;
+}
+
+export const BookingModals: React.FC = ({
+ showRescheduleModal,
+ rescheduleBooking,
+ rescheduleDate,
+ rescheduleTime,
+ rescheduleReason,
+ isRescheduling,
+ onRescheduleClose,
+ onRescheduleSubmit,
+ onRescheduleDateChange,
+ onRescheduleTimeChange,
+ onRescheduleReasonChange,
+ showRejectModal,
+ rejectReason,
+ isDeclining,
+ onRejectClose,
+ onRejectSubmit,
+ onRejectReasonChange,
+ showFilterModal,
+ eventTypes,
+ eventTypesLoading,
+ selectedEventTypeId,
+ onFilterClose,
+ onEventTypeSelect,
+ showBookingActionsModal,
+ selectedBooking,
+ onActionsClose,
+ onReschedule,
+ onCancel,
+}) => {
+ return (
+ <>
+ {/* Filter Modal - Only rendered if props are provided (non-iOS) */}
+ {showFilterModal !== undefined && onFilterClose && onEventTypeSelect ? (
+
+
+ e.stopPropagation()}
+ className="w-[85%] max-w-[350px] rounded-2xl bg-white p-5"
+ >
+
+ Filter by Event Type
+
+
+ {eventTypesLoading ? (
+
+
+ Loading event types...
+
+ ) : (
+
+ {eventTypes?.map((eventType) => (
+ onEventTypeSelect(eventType.id, eventType.title)}
+ >
+
+ {eventType.title}
+
+ {selectedEventTypeId === eventType.id ? (
+
+ ) : null}
+
+ ))}
+
+ {eventTypes?.length === 0 ? (
+
+ No event types found
+
+ ) : null}
+
+ )}
+
+
+
+ ) : null}
+
+ {/* Booking Actions Modal */}
+ = new Date() &&
+ selectedBooking.status?.toUpperCase() !== "PENDING"
+ : false
+ }
+ isPast={
+ selectedBooking
+ ? new Date(selectedBooking.endTime || selectedBooking.end || "") < new Date()
+ : false
+ }
+ isCancelled={selectedBooking?.status?.toUpperCase() === "CANCELLED"}
+ isUnconfirmed={selectedBooking?.status?.toUpperCase() === "PENDING"}
+ onReschedule={onReschedule}
+ 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={onCancel}
+ />
+
+ {/* Reschedule Modal */}
+
+
+ {rescheduleBooking ? (
+ <>
+
+ Reschedule "{rescheduleBooking.title}"
+
+
+ {/* Date Input */}
+
+
+ New Date (YYYY-MM-DD)
+
+
+
+
+ {/* Time Input */}
+
+
+ New Time (HH:MM, 24-hour format)
+
+
+
+
+ {/* Reason Input */}
+
+ Reason (optional)
+
+
+
+ {/* Submit Button */}
+
+ {isRescheduling ? (
+
+ ) : (
+
+ Reschedule Booking
+
+ )}
+
+
+ {/* Cancel Button */}
+
+ Cancel
+
+ >
+ ) : null}
+
+
+
+ {/* Reject Booking Modal */}
+
+
+ e.stopPropagation()}
+ >
+
+ {/* Title */}
+
+ Reject the booking request?
+
+
+ {/* Description */}
+
+ Are you sure you want to reject the booking? We'll let the person who tried to book
+ know. You can provide a reason below.
+
+
+ {/* Reason Input */}
+
+
+ Reason for rejecting (Optional)
+
+
+
+
+ {/* Separator */}
+
+
+ {/* Buttons Row */}
+
+ {/* Close Button */}
+
+ Close
+
+
+ {/* Reject Button */}
+
+ Reject the booking
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/companion/hooks/index.ts b/companion/hooks/index.ts
index f459fcb9c0..b27b058be8 100644
--- a/companion/hooks/index.ts
+++ b/companion/hooks/index.ts
@@ -65,6 +65,15 @@ export {
type UpdateUserProfileInput,
} from "./useUserProfile";
+// UI State Management hooks
+export {
+ useActiveBookingFilter,
+ type BookingFilter,
+ type BookingFilterOption,
+} from "./useActiveBookingFilter";
+
+export { useBookingActions } from "./useBookingActions";
+
// Re-export query keys for advanced use cases
export { queryKeys } from "../config/cache.config";
diff --git a/companion/hooks/useActiveBookingFilter.tsx b/companion/hooks/useActiveBookingFilter.tsx
new file mode 100644
index 0000000000..2613861505
--- /dev/null
+++ b/companion/hooks/useActiveBookingFilter.tsx
@@ -0,0 +1,135 @@
+import { useState, useMemo, useCallback } from "react";
+import type { NativeSyntheticEvent } from "react-native";
+import type { BookingFilters } from "./useBookings";
+
+export type BookingFilter = "upcoming" | "unconfirmed" | "past" | "cancelled";
+
+export interface BookingFilterOption {
+ key: BookingFilter;
+ label: string;
+}
+
+const FILTER_OPTIONS: BookingFilterOption[] = [
+ { key: "upcoming", label: "Upcoming" },
+ { key: "unconfirmed", label: "Unconfirmed" },
+ { key: "past", label: "Past" },
+ { key: "cancelled", label: "Cancelled" },
+];
+
+/**
+ * Hook to manage active booking filter state and provide filter-related utilities.
+ *
+ * Features:
+ * - Manages active filter state
+ * - Provides filter options and labels for UI components
+ * - Converts filter selection to API-compatible parameters
+ * - Handles segmented control integration
+ * - Supports change callbacks for dependent filter cleanup
+ *
+ * @param initialFilter - Initial filter value (defaults to "upcoming")
+ * @param onFilterChange - Optional callback invoked when filter changes
+ * @returns Object containing filter state, handlers, and computed values
+ *
+ * @example
+ * ```tsx
+ * const {
+ * activeFilter,
+ * filterLabels,
+ * activeIndex,
+ * filterParams,
+ * handleFilterChange,
+ * handleSegmentChange,
+ * } = useActiveBookingFilter("upcoming", () => {
+ * // Clear dependent filters like search query, event type filter, etc.
+ * setSearchQuery("");
+ * setSelectedEventTypeId(null);
+ * });
+ *
+ * // Use with React Query
+ * const { data: bookings } = useBookings(filterParams);
+ *
+ * // Use with SegmentedControl
+ *
+ * ```
+ */
+export function useActiveBookingFilter(
+ initialFilter: BookingFilter = "upcoming",
+ onFilterChange?: (filter: BookingFilter) => void
+) {
+ const [activeFilter, setActiveFilter] = useState(initialFilter);
+
+ /**
+ * Converts the active filter to API-compatible query parameters
+ */
+ const filterParams = useMemo(() => {
+ switch (activeFilter) {
+ case "upcoming":
+ return { status: ["upcoming"], limit: 50 };
+ case "unconfirmed":
+ return { status: ["unconfirmed"], limit: 50 };
+ case "past":
+ return { status: ["past"], limit: 100 };
+ case "cancelled":
+ return { status: ["cancelled"], limit: 100 };
+ default:
+ return { status: ["upcoming"], limit: 50 };
+ }
+ }, [activeFilter]);
+
+ /**
+ * Array of filter labels for UI components (e.g., SegmentedControl)
+ */
+ const filterLabels = useMemo(() => FILTER_OPTIONS.map((option) => option.label), []);
+
+ /**
+ * Current active filter index for UI components
+ */
+ const activeIndex = useMemo(
+ () => FILTER_OPTIONS.findIndex((option) => option.key === activeFilter),
+ [activeFilter]
+ );
+
+ /**
+ * Changes the active filter and invokes the optional callback
+ */
+ const handleFilterChange = useCallback(
+ (filter: BookingFilter) => {
+ setActiveFilter(filter);
+ onFilterChange?.(filter);
+ },
+ [onFilterChange]
+ );
+
+ /**
+ * Handler for React Native SegmentedControl onChange event
+ */
+ const handleSegmentChange = useCallback(
+ (event: NativeSyntheticEvent<{ selectedSegmentIndex: number }>) => {
+ const { selectedSegmentIndex } = event.nativeEvent;
+ const selectedFilter = FILTER_OPTIONS[selectedSegmentIndex];
+ if (selectedFilter) {
+ handleFilterChange(selectedFilter.key);
+ }
+ },
+ [handleFilterChange]
+ );
+
+ return {
+ // State
+ activeFilter,
+
+ // Computed values
+ filterOptions: FILTER_OPTIONS,
+ filterLabels,
+ activeIndex,
+ filterParams,
+
+ // Handlers
+ handleFilterChange,
+ handleSegmentChange,
+ };
+}
diff --git a/companion/hooks/useBookingActions.ts b/companion/hooks/useBookingActions.ts
new file mode 100644
index 0000000000..4ec9a61534
--- /dev/null
+++ b/companion/hooks/useBookingActions.ts
@@ -0,0 +1,361 @@
+import { useState } from "react";
+import { Alert } from "react-native";
+import type { useRouter } from "expo-router";
+import type { Booking } from "../services/calcom";
+import { showErrorAlert } from "../utils/alerts";
+
+interface UseBookingActionsParams {
+ router: ReturnType;
+ cancelMutation: (
+ params: { uid: string; reason: string },
+ options?: {
+ onSuccess?: () => void;
+ onError?: (error: Error) => void;
+ }
+ ) => void;
+ confirmMutation: (
+ params: { uid: string },
+ options?: {
+ onSuccess?: () => void;
+ onError?: (error: Error) => void;
+ }
+ ) => void;
+ declineMutation: (
+ params: { uid: string; reason?: string },
+ options?: {
+ onSuccess?: () => void;
+ onError?: (error: Error) => void;
+ }
+ ) => void;
+ rescheduleMutation: (
+ params: { uid: string; start: string; reschedulingReason?: string },
+ options?: {
+ onSuccess?: () => void;
+ onError?: (error: Error) => void;
+ }
+ ) => void;
+ isConfirming: boolean;
+ isDeclining: boolean;
+ isRescheduling: boolean;
+}
+
+export const useBookingActions = ({
+ router,
+ cancelMutation,
+ confirmMutation,
+ declineMutation,
+ rescheduleMutation,
+ isConfirming,
+ isDeclining,
+ isRescheduling,
+}: UseBookingActionsParams) => {
+ // Reschedule modal state
+ const [showRescheduleModal, setShowRescheduleModal] = useState(false);
+ const [rescheduleBooking, setRescheduleBooking] = useState(null);
+ const [rescheduleDate, setRescheduleDate] = useState("");
+ const [rescheduleTime, setRescheduleTime] = useState("");
+ const [rescheduleReason, setRescheduleReason] = useState("");
+
+ // Reject modal state
+ const [showRejectModal, setShowRejectModal] = useState(false);
+ const [rejectBooking, setRejectBooking] = useState(null);
+ const [rejectReason, setRejectReason] = useState("");
+
+ // Selected booking for actions modal
+ const [selectedBooking, setSelectedBooking] = useState(null);
+
+ /**
+ * Navigate to booking detail page
+ */
+ const handleBookingPress = (booking: Booking) => {
+ router.push({
+ pathname: "/booking-detail",
+ params: { uid: booking.uid },
+ });
+ };
+
+ /**
+ * Open reschedule modal with pre-filled data
+ */
+ const handleRescheduleBooking = (booking: Booking) => {
+ // Pre-fill with the current booking date/time
+ const currentDate = new Date(booking.startTime);
+ const dateStr = currentDate.toISOString().split("T")[0]; // YYYY-MM-DD
+ const timeStr = currentDate.toTimeString().slice(0, 5); // HH:MM
+
+ setRescheduleBooking(booking);
+ setRescheduleDate(dateStr);
+ setRescheduleTime(timeStr);
+ setRescheduleReason("");
+ setShowRescheduleModal(true);
+ };
+
+ /**
+ * Validate and submit reschedule request
+ */
+ const handleSubmitReschedule = () => {
+ if (!rescheduleBooking || !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();
+
+ rescheduleMutation(
+ {
+ uid: rescheduleBooking.uid,
+ start: startUtc,
+ reschedulingReason: rescheduleReason || undefined,
+ },
+ {
+ onSuccess: () => {
+ setShowRescheduleModal(false);
+ setRescheduleBooking(null);
+ Alert.alert("Success", "Booking rescheduled successfully");
+ },
+ onError: (error) => {
+ showErrorAlert("Error", error.message || "Failed to reschedule booking");
+ },
+ }
+ );
+ };
+
+ /**
+ * Close reschedule modal and reset state
+ */
+ const handleCloseRescheduleModal = () => {
+ setShowRescheduleModal(false);
+ setRescheduleBooking(null);
+ };
+
+ /**
+ * Show alert and cancel booking (iOS Alert.prompt pattern)
+ */
+ const handleCancelBooking = (booking: Booking) => {
+ Alert.alert("Cancel Event", `Are you sure you want to cancel "${booking.title}"?`, [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Cancel Event",
+ style: "destructive",
+ onPress: () => {
+ // Prompt for cancellation reason
+ Alert.prompt(
+ "Cancellation Reason",
+ "Please provide a reason for cancelling this booking:",
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Cancel Event",
+ style: "destructive",
+ onPress: (reason) => {
+ const cancellationReason = reason?.trim() || "Event cancelled by host";
+ cancelMutation(
+ { uid: booking.uid, reason: cancellationReason },
+ {
+ onSuccess: () => {
+ Alert.alert("Success", "Event cancelled successfully");
+ },
+ onError: (error) => {
+ console.error("Failed to cancel booking:", error);
+ showErrorAlert("Error", "Failed to cancel event. Please try again.");
+ },
+ }
+ );
+ },
+ },
+ ],
+ "plain-text",
+ "",
+ "default"
+ );
+ },
+ },
+ ]);
+ };
+
+ /**
+ * Show alert and confirm booking
+ */
+ const handleConfirmBooking = (booking: Booking) => {
+ Alert.alert("Confirm Booking", `Are you sure you want to confirm "${booking.title}"?`, [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Confirm",
+ onPress: () => {
+ confirmMutation(
+ { uid: booking.uid },
+ {
+ onSuccess: () => {
+ Alert.alert("Success", "Booking confirmed successfully");
+ },
+ onError: (error) => {
+ showErrorAlert("Error", error.message || "Failed to confirm booking");
+ },
+ }
+ );
+ },
+ },
+ ]);
+ };
+
+ /**
+ * Open reject modal (used by iOS for custom modal UI)
+ */
+ const handleOpenRejectModal = (booking: Booking) => {
+ setRejectBooking(booking);
+ setRejectReason("");
+ setShowRejectModal(true);
+ };
+
+ /**
+ * Show alert and reject booking (Android Alert.prompt pattern)
+ */
+ const handleRejectBooking = (booking: Booking) => {
+ Alert.alert("Decline Booking", `Are you sure you want to decline "${booking.title}"?`, [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Decline",
+ style: "destructive",
+ onPress: () => {
+ // Show optional reason input
+ Alert.prompt(
+ "Decline Reason",
+ "Optionally provide a reason for declining (press OK to skip)",
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "OK",
+ onPress: (reason?: string) => {
+ declineMutation(
+ { uid: booking.uid, reason: reason || undefined },
+ {
+ onSuccess: () => {
+ Alert.alert("Success", "Booking declined successfully");
+ },
+ onError: (error) => {
+ showErrorAlert("Error", error.message || "Failed to decline booking");
+ },
+ }
+ );
+ },
+ },
+ ],
+ "plain-text",
+ "",
+ "default"
+ );
+ },
+ },
+ ]);
+ };
+
+ /**
+ * Submit rejection with reason (used by iOS custom modal)
+ */
+ const handleSubmitReject = () => {
+ if (!rejectBooking) return;
+
+ declineMutation(
+ { uid: rejectBooking.uid, reason: rejectReason || undefined },
+ {
+ onSuccess: () => {
+ setShowRejectModal(false);
+ setRejectBooking(null);
+ setRejectReason("");
+ Alert.alert("Success", "Booking rejected successfully");
+ },
+ onError: (error) => {
+ showErrorAlert("Error", "Failed to reject booking. Please try again.");
+ },
+ }
+ );
+ };
+
+ /**
+ * Close reject modal and reset state
+ */
+ const handleCloseRejectModal = () => {
+ setShowRejectModal(false);
+ setRejectBooking(null);
+ setRejectReason("");
+ };
+
+ /**
+ * Inline confirm handler (for use directly in JSX)
+ */
+ const handleInlineConfirm = (booking: Booking) => {
+ confirmMutation(
+ { uid: booking.uid },
+ {
+ onSuccess: () => {
+ Alert.alert("Success", "Booking confirmed successfully");
+ },
+ onError: (error) => {
+ showErrorAlert("Error", "Failed to confirm booking. Please try again.");
+ },
+ }
+ );
+ };
+
+ return {
+ // Reschedule state
+ showRescheduleModal,
+ setShowRescheduleModal,
+ rescheduleBooking,
+ rescheduleDate,
+ setRescheduleDate,
+ rescheduleTime,
+ setRescheduleTime,
+ rescheduleReason,
+ setRescheduleReason,
+
+ // Reject state
+ showRejectModal,
+ setShowRejectModal,
+ rejectBooking,
+ rejectReason,
+ setRejectReason,
+
+ // Selected booking state
+ selectedBooking,
+ setSelectedBooking,
+
+ // Handlers
+ handleBookingPress,
+ handleRescheduleBooking,
+ handleSubmitReschedule,
+ handleCloseRescheduleModal,
+ handleCancelBooking,
+ handleConfirmBooking,
+ handleOpenRejectModal,
+ handleRejectBooking,
+ handleSubmitReject,
+ handleCloseRejectModal,
+ handleInlineConfirm,
+
+ // Loading states (pass through)
+ isConfirming,
+ isDeclining,
+ isRescheduling,
+ };
+};
diff --git a/companion/utils/bookings-utils.ts b/companion/utils/bookings-utils.ts
new file mode 100644
index 0000000000..e7f2555475
--- /dev/null
+++ b/companion/utils/bookings-utils.ts
@@ -0,0 +1,359 @@
+import type { BookingFilter } from "../hooks";
+import type { Booking } from "../services/calcom";
+
+export const getEmptyStateContent = (activeFilter: BookingFilter) => {
+ switch (activeFilter) {
+ case "upcoming":
+ return {
+ icon: "calendar-outline" as const,
+ title: "No upcoming bookings",
+ text: "As soon as someone books a time with you it will show up here.",
+ };
+ case "unconfirmed":
+ return {
+ icon: "calendar-outline" as const,
+ title: "No unconfirmed bookings",
+ text: "Your unconfirmed bookings will show up here.",
+ };
+ case "past":
+ return {
+ icon: "calendar-outline" as const,
+ title: "No past bookings",
+ text: "Your past bookings will show up here.",
+ };
+ case "cancelled":
+ return {
+ icon: "calendar-outline" as const,
+ title: "No cancelled bookings",
+ text: "Your canceled bookings will show up here.",
+ };
+ default:
+ return {
+ icon: "calendar-outline" as const,
+ title: "No bookings found",
+ text: "Your bookings will appear here.",
+ };
+ }
+};
+
+/**
+ * Format a date string to a time string in 12-hour format
+ * @param dateString ISO date string
+ * @returns Formatted time string (e.g., "2:30 PM")
+ */
+export const formatTime = (dateString: string): string => {
+ if (!dateString) {
+ return "";
+ }
+ try {
+ const date = new Date(dateString);
+ if (isNaN(date.getTime())) {
+ console.warn("Invalid date string:", dateString);
+ return "";
+ }
+ return date.toLocaleTimeString("en-US", {
+ hour: "numeric",
+ minute: "2-digit",
+ hour12: true,
+ });
+ } catch (error) {
+ console.error("Error formatting time:", error, dateString);
+ return "";
+ }
+};
+
+/**
+ * Format a date string to a human-readable date
+ * @param dateString ISO date string
+ * @param isUpcoming Whether the booking is upcoming (affects format)
+ * @returns Formatted date string
+ */
+export const formatDate = (dateString: string, isUpcoming: boolean): string => {
+ if (!dateString) {
+ return "";
+ }
+ try {
+ const date = new Date(dateString);
+ if (isNaN(date.getTime())) {
+ console.warn("Invalid date string:", dateString);
+ return "";
+ }
+ const bookingYear = date.getFullYear();
+ const currentYear = new Date().getFullYear();
+ const isDifferentYear = bookingYear !== currentYear;
+
+ if (isUpcoming) {
+ // Format: "ddd, D MMM" or "ddd, D MMM YYYY" if different year
+ const weekday = date.toLocaleDateString("en-US", { weekday: "short" });
+ const day = date.getDate();
+ const month = date.toLocaleDateString("en-US", { month: "short" });
+ if (isDifferentYear) {
+ return `${weekday}, ${day} ${month} ${bookingYear}`;
+ }
+ return `${weekday}, ${day} ${month}`;
+ } else {
+ // Format: "D MMMM YYYY" for past bookings
+ return date.toLocaleDateString("en-US", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ });
+ }
+ } catch (error) {
+ console.error("Error formatting date:", error, dateString);
+ return "";
+ }
+};
+
+/**
+ * Format a date string to month and year
+ * @param dateString ISO date string
+ * @returns Formatted month and year string (e.g., "December 2024" or "This month")
+ */
+export const formatMonthYear = (dateString: string): string => {
+ if (!dateString) return "";
+ try {
+ const date = new Date(dateString);
+ if (isNaN(date.getTime())) {
+ return "";
+ }
+ const now = new Date();
+ const isCurrentMonth =
+ date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth();
+
+ if (isCurrentMonth) {
+ return "This month";
+ }
+
+ return date.toLocaleDateString("en-US", {
+ month: "long",
+ year: "numeric",
+ });
+ } catch (error) {
+ return "";
+ }
+};
+
+/**
+ * Get a unique key for a month and year from a date string
+ * @param dateString ISO date string
+ * @returns Key in format "YYYY-M" (e.g., "2024-11")
+ */
+export const getMonthYearKey = (dateString: string): string => {
+ if (!dateString) return "";
+ try {
+ const date = new Date(dateString);
+ if (isNaN(date.getTime())) {
+ return "";
+ }
+ return `${date.getFullYear()}-${date.getMonth()}`;
+ } catch (error) {
+ return "";
+ }
+};
+
+/**
+ * Type definition for list items (used in FlatList)
+ */
+export type ListItem =
+ | { type: "monthHeader"; monthYear: string; key: string }
+ | { type: "booking"; booking: Booking; key: string };
+
+/**
+ * Group bookings by month for display in a sectioned list
+ * @param bookings Array of bookings to group
+ * @returns Array of list items with month headers and bookings
+ */
+export const groupBookingsByMonth = (bookings: Booking[]): ListItem[] => {
+ const grouped: ListItem[] = [];
+ let currentMonthYear: string | null = null;
+
+ bookings.forEach((booking) => {
+ const startTime = booking.start || booking.startTime || "";
+ if (!startTime) return;
+
+ const monthYearKey = getMonthYearKey(startTime);
+ const monthYear = formatMonthYear(startTime);
+
+ if (monthYearKey !== currentMonthYear) {
+ currentMonthYear = monthYearKey;
+ grouped.push({
+ type: "monthHeader",
+ monthYear,
+ key: `month-${monthYearKey}`,
+ });
+ }
+
+ grouped.push({
+ type: "booking",
+ booking,
+ key: booking.id.toString(),
+ });
+ });
+
+ return grouped;
+};
+
+/**
+ * Search/filter bookings by query string
+ * @param bookings Array of bookings to filter
+ * @param searchQuery Search query string
+ * @returns Filtered bookings
+ */
+export const searchBookings = (bookings: Booking[], searchQuery: string): Booking[] => {
+ if (searchQuery.trim() === "") {
+ return bookings;
+ }
+
+ const searchLower = searchQuery.toLowerCase();
+ return bookings.filter(
+ (booking) =>
+ // Search in booking title
+ booking.title?.toLowerCase().includes(searchLower) ||
+ // Search in booking description
+ booking.description?.toLowerCase().includes(searchLower) ||
+ // Search in event type title
+ booking.eventType?.title?.toLowerCase().includes(searchLower) ||
+ // Search in attendee names
+ (booking.attendees &&
+ booking.attendees.some((attendee) => attendee.name?.toLowerCase().includes(searchLower))) ||
+ // Search in attendee emails
+ (booking.attendees &&
+ booking.attendees.some((attendee) =>
+ attendee.email?.toLowerCase().includes(searchLower)
+ )) ||
+ // Search in location
+ booking.location?.toLowerCase().includes(searchLower) ||
+ // Search in user name
+ booking.user?.name?.toLowerCase().includes(searchLower) ||
+ // Search in user email
+ booking.user?.email?.toLowerCase().includes(searchLower)
+ );
+};
+
+/**
+ * Filter bookings by event type ID
+ * @param bookings Array of bookings to filter
+ * @param eventTypeId Event type ID to filter by (null means no filter)
+ * @returns Filtered bookings
+ */
+export const filterByEventType = (bookings: Booking[], eventTypeId: number | null): Booking[] => {
+ if (eventTypeId === null) {
+ return bookings;
+ }
+ return bookings.filter((booking) => booking.eventTypeId === eventTypeId);
+};
+
+/**
+ * Format a date string to a complete date/time string
+ * @param dateString ISO date string
+ * @returns Formatted date/time string (e.g., "Mon, Dec 25, 2:30 PM")
+ */
+export const formatDateTime = (dateString: string): string => {
+ const date = new Date(dateString);
+ return date.toLocaleDateString("en-US", {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+};
+
+/**
+ * Get color for booking status
+ * @param status Booking status string
+ * @returns Hex color code
+ */
+export const getStatusColor = (status: string): string => {
+ // API v2 2024-08-13 returns status in lowercase, so normalize to uppercase for comparison
+ const normalizedStatus = status.toUpperCase();
+ switch (normalizedStatus) {
+ case "ACCEPTED":
+ return "#34C759";
+ case "PENDING":
+ return "#FF9500";
+ case "CANCELLED":
+ return "#FF3B30";
+ case "REJECTED":
+ return "#FF3B30";
+ default:
+ return "#666";
+ }
+};
+
+/**
+ * Format status text for display
+ * @param status Booking status string
+ * @returns Capitalized status text
+ */
+export const formatStatusText = (status: string): string => {
+ // Capitalize first letter for display
+ return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
+};
+
+/**
+ * Get formatted display string for host and attendees
+ * @param booking Booking object
+ * @param currentUserEmail Current user's email (optional)
+ * @returns Formatted string or null if no host/attendees
+ */
+export const getHostAndAttendeesDisplay = (
+ booking: Booking,
+ currentUserEmail?: string
+): string | null => {
+ const hasHostOrAttendees =
+ (booking.hosts && booking.hosts.length > 0) ||
+ booking.user ||
+ (booking.attendees && booking.attendees.length > 0);
+
+ if (!hasHostOrAttendees) return null;
+
+ const hostEmail = booking.hosts?.[0]?.email?.toLowerCase() || booking.user?.email?.toLowerCase();
+ const isCurrentUserHost =
+ currentUserEmail && hostEmail && currentUserEmail.toLowerCase() === hostEmail;
+
+ const hostName = isCurrentUserHost
+ ? "You"
+ : booking.hosts?.[0]?.name ||
+ booking.hosts?.[0]?.email ||
+ booking.user?.name ||
+ booking.user?.email;
+
+ const attendeesDisplay =
+ booking.attendees && booking.attendees.length > 0
+ ? booking.attendees.length === 1
+ ? booking.attendees[0].name || booking.attendees[0].email
+ : booking.attendees
+ .slice(0, 2)
+ .map((att) => att.name || att.email)
+ .join(", ") +
+ (booking.attendees.length > 2 ? ` and ${booking.attendees.length - 2} more` : "")
+ : null;
+
+ if (hostName && attendeesDisplay) {
+ return `${hostName} and ${attendeesDisplay}`;
+ } else if (hostName) {
+ return hostName;
+ } else if (attendeesDisplay) {
+ return attendeesDisplay;
+ }
+ return null;
+};
+
+/**
+ * Get shareable booking details message
+ * @param booking Booking object
+ * @returns Formatted booking details string
+ */
+export const getBookingDetailsMessage = (booking: Booking): string => {
+ const attendeesList = booking.attendees?.map((att) => att.name).join(", ") || "No attendees";
+ const startTime = booking.start || booking.startTime || "";
+ const endTime = booking.end || booking.endTime || "";
+
+ return `${booking.description ? `${booking.description}\n\n` : ""}Time: ${formatDateTime(
+ startTime
+ )} - ${formatTime(endTime)}\nAttendees: ${attendeesList}\nStatus: ${formatStatusText(booking.status)}${
+ booking.location ? `\nLocation: ${booking.location}` : ""
+ }`;
+};
diff --git a/companion/utils/meetings-utils.ts b/companion/utils/meetings-utils.ts
new file mode 100644
index 0000000000..4beebfb979
--- /dev/null
+++ b/companion/utils/meetings-utils.ts
@@ -0,0 +1,93 @@
+import { getAppIconUrl } from "./getAppIconUrl";
+
+// Helper to extract clean meeting URL from potentially wrapped URLs
+function extractMeetingUrl(location: string): string {
+ // Check if it's a goo.gl redirect URL with embedded meet.google.com link
+ if (location.includes("goo.gl") && location.includes("meet.google.com")) {
+ // Extract the actual meet.google.com URL from the redirect
+ const meetMatch = location.match(/meet\.google\.com\/[a-z]+-[a-z]+-[a-z]+/i);
+ if (meetMatch) {
+ return `https://${meetMatch[0]}`;
+ }
+ }
+ return location;
+}
+
+// Helper to detect meeting type from location URL and get icon/label
+export const getMeetingInfo = (
+ location?: string
+): { appId: string; label: string; iconUrl: string | null; cleanUrl: string } | null => {
+ if (!location) return null;
+
+ // Check if it's a URL
+ if (!location.match(/^https?:\/\//)) return null;
+
+ const lowerLocation = location.toLowerCase();
+ const cleanUrl = extractMeetingUrl(location);
+
+ // Cal Video
+ if (lowerLocation.includes("cal.com/video") || lowerLocation.includes("cal.video")) {
+ return {
+ appId: "cal-video",
+ label: "Join Cal Video",
+ iconUrl: getAppIconUrl("daily_video", "cal-video"),
+ cleanUrl,
+ };
+ }
+
+ // Google Meet (including goo.gl redirect URLs)
+ if (
+ lowerLocation.includes("meet.google.com") ||
+ (lowerLocation.includes("goo.gl") && lowerLocation.includes("meet"))
+ ) {
+ return {
+ appId: "google-meet",
+ label: "Join Google Meet",
+ iconUrl: getAppIconUrl("google_video", "google-meet"),
+ cleanUrl,
+ };
+ }
+
+ // Zoom
+ if (lowerLocation.includes("zoom.us") || lowerLocation.includes("zoom.com")) {
+ return {
+ appId: "zoom",
+ label: "Join Zoom",
+ iconUrl: getAppIconUrl("zoom_video", "zoom"),
+ cleanUrl,
+ };
+ }
+
+ // Microsoft Teams
+ if (lowerLocation.includes("teams.microsoft.com") || lowerLocation.includes("teams.live.com")) {
+ return {
+ appId: "msteams",
+ label: "Join Microsoft Teams",
+ iconUrl: getAppIconUrl("office365_video", "msteams"),
+ cleanUrl,
+ };
+ }
+
+ // Webex
+ if (lowerLocation.includes("webex.com")) {
+ return {
+ appId: "webex",
+ label: "Join Webex",
+ iconUrl: getAppIconUrl("webex_video", "webex"),
+ cleanUrl,
+ };
+ }
+
+ // Jitsi
+ if (lowerLocation.includes("meet.jit.si") || lowerLocation.includes("jitsi")) {
+ return {
+ appId: "jitsi",
+ label: "Join Jitsi",
+ iconUrl: getAppIconUrl("jitsi_video", "jitsi"),
+ cleanUrl,
+ };
+ }
+
+ // Not a recognized conferencing app
+ return null;
+};